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
+242
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();
});
+80 -41
View File
@@ -3,22 +3,32 @@ let tasks = [];
let selectedGoalId = null;
let selectedTaskId = null;
let sortableInstance = null;
let persistTimer = null;
let currentUser = null;
async function loadGoals() {
try {
goals = await get("/api/goals");
currentUser = await get("/api/auth/me");
const selector = document.getElementById("goal-selector");
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>`
).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;
selector.value = selectedGoalId;
await loadTasks();
}
await loadTasks();
} catch (error) {
console.error("Failed to load goals:", error);
}
@@ -34,23 +44,47 @@ async function loadTasks() {
initSortable();
initScrollFocus();
const doingTask = tasks.find(t => t.status === "doing");
if (doingTask) {
scrollToTask(doingTask.id);
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(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) {
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() {
const container = document.getElementById("tasks-list");
if (tasks.length === 0) {
container.innerHTML = `
<div class="empty-state">
@@ -71,11 +105,11 @@ function renderTasks() {
function initSortable() {
const container = document.getElementById("tasks-list");
if (sortableInstance) {
sortableInstance.destroy();
}
sortableInstance = Sortable.create(container, {
animation: 150,
ghostClass: "sortable-ghost",
@@ -84,15 +118,15 @@ function initSortable() {
onEnd: async function(evt) {
const taskId = evt.item.dataset.taskId;
const newIndex = evt.newIndex;
const prevTask = tasks[newIndex - 1];
const nextTask = tasks[newIndex + 1];
let prevOrder = prevTask ? prevTask.order : 0;
let nextOrder = nextTask ? nextTask.order : prevOrder + 2;
const newOrder = (prevOrder + nextOrder) / 2;
try {
await patch(`/api/tasks/${taskId}/order`, { order: newOrder });
await loadTasks();
@@ -117,10 +151,10 @@ function scrollToTask(taskId) {
function initScrollFocus() {
const scrollView = document.getElementById("scroll-view");
scrollView.removeEventListener("scroll", handleScrollFocus);
scrollView.addEventListener("scroll", handleScrollFocus);
handleScrollFocus();
}
@@ -150,8 +184,12 @@ function handleScrollFocus() {
if (closestItem) {
closestItem.classList.add("in-focus");
const taskId = parseInt(closestItem.dataset.taskId);
clearTimeout(persistTimer);
persistTimer = setTimeout(() => persistSelectedTask(taskId), 400);
if (isLandscapeMode()) {
const taskId = parseInt(closestItem.dataset.taskId);
if (taskId !== selectedTaskId) {
selectTask(taskId);
}
@@ -188,27 +226,27 @@ function closeSidePanel() {
async function saveTask() {
if (!selectedTaskId) return;
const error = document.getElementById("side-panel-error");
error.textContent = "";
const title = document.getElementById("edit-task-title").value.trim();
const desc = document.getElementById("edit-task-desc").value;
const status = document.getElementById("edit-task-status").value;
if (!title) {
error.textContent = "Title is required";
return;
}
try {
await put(`/api/tasks/${selectedTaskId}`, { title, desc });
const currentTask = tasks.find(t => t.id === selectedTaskId);
if (status !== currentTask?.status) {
await patch(`/api/tasks/${selectedTaskId}/status`, { status });
}
await loadTasks();
} catch (err) {
error.textContent = err.message;
@@ -217,9 +255,9 @@ async function saveTask() {
async function deleteTask() {
if (!selectedTaskId) return;
if (!confirm("Are you sure you want to delete this task?")) return;
try {
await del(`/api/tasks/${selectedTaskId}`);
closeSidePanel();
@@ -246,21 +284,21 @@ async function handleTaskSubmit(event) {
event.preventDefault();
const error = document.getElementById("task-error");
error.textContent = "";
const title = document.getElementById("task-title").value.trim();
const desc = document.getElementById("task-desc").value;
if (!title) {
error.textContent = "Title is required";
return;
}
const goalId = parseInt(document.getElementById("goal-selector").value);
if (!goalId) {
error.textContent = "Please select a goal first";
return;
}
try {
await post("/api/tasks", { goal_id: goalId, title, desc });
closeTaskModal();
@@ -284,16 +322,16 @@ function formatTime(isoString) {
function initWheelScroll() {
const scrollView = document.getElementById("scroll-view");
scrollView.addEventListener("wheel", (e) => {
e.preventDefault();
const taskItem = scrollView.querySelector(".task-item");
if (!taskItem) return;
const taskHeight = taskItem.offsetHeight + 8;
const direction = e.deltaY > 0 ? 1 : -1;
scrollView.scrollBy({
top: taskHeight * direction,
behavior: "smooth"
@@ -302,20 +340,21 @@ function initWheelScroll() {
}
document.addEventListener("DOMContentLoaded", () => {
document.getElementById("goal-selector").addEventListener("change", (e) => {
document.getElementById("goal-selector").addEventListener("change", async (e) => {
selectedGoalId = parseInt(e.target.value);
await patch("/api/user/selected-goal", { goal_id: selectedGoalId });
loadTasks();
});
document.getElementById("create-task-btn").addEventListener("click", openTaskModal);
document.getElementById("task-modal-close").addEventListener("click", closeTaskModal);
document.getElementById("task-modal-cancel").addEventListener("click", closeTaskModal);
document.getElementById("task-form").addEventListener("submit", handleTaskSubmit);
document.getElementById("side-panel-close").addEventListener("click", closeSidePanel);
document.getElementById("save-task-btn").addEventListener("click", saveTask);
document.getElementById("delete-task-btn").addEventListener("click", deleteTask);
initWheelScroll();
loadGoals();
});