Yuyao Huang 3c325bdb0f 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
2026-05-08 17:42:42 +08:00

243 lines
8.0 KiB
JavaScript

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