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:
@@ -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
@@ -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();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user