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