Initial commit: GoalsBreakDown web app
- Flask backend with TinyDB database - Multi-user auth with bcrypt password hashing - Goal CRUD with activation/deactivation and per-user limits - Task CRUD with status tracking (todo/doing/pending/done) - Focus rule: one doing task per goal - Time picker-style scroll view with drag-and-drop reordering - Admin panel for user management - uv environment management
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
const API_BASE = "";
|
||||
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const url = API_BASE + endpoint;
|
||||
const config = {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
credentials: "same-origin",
|
||||
...options,
|
||||
};
|
||||
|
||||
if (options.body && typeof options.body === "object") {
|
||||
config.body = JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
const response = await fetch(url, config);
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || "Request failed");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function get(endpoint) {
|
||||
return apiRequest(endpoint, { method: "GET" });
|
||||
}
|
||||
|
||||
async function post(endpoint, body) {
|
||||
return apiRequest(endpoint, { method: "POST", body });
|
||||
}
|
||||
|
||||
async function put(endpoint, body) {
|
||||
return apiRequest(endpoint, { method: "PUT", body });
|
||||
}
|
||||
|
||||
async function del(endpoint) {
|
||||
return apiRequest(endpoint, { method: "DELETE" });
|
||||
}
|
||||
|
||||
async function patch(endpoint, body) {
|
||||
return apiRequest(endpoint, { method: "PATCH", body });
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
async function checkAuth() {
|
||||
try {
|
||||
const user = await get("/api/auth/me");
|
||||
const userInfo = document.getElementById("user-info");
|
||||
if (userInfo) {
|
||||
userInfo.textContent = user.username;
|
||||
}
|
||||
const adminLink = document.getElementById("admin-link");
|
||||
if (adminLink && user.role === "admin") {
|
||||
adminLink.style.display = "inline";
|
||||
}
|
||||
return user;
|
||||
} catch (error) {
|
||||
window.location.href = "/login";
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const errorMessage = document.getElementById("error-message");
|
||||
errorMessage.textContent = "";
|
||||
|
||||
const username = form.username.value.trim();
|
||||
const password = form.password.value;
|
||||
|
||||
try {
|
||||
await post("/api/auth/login", { username, password });
|
||||
window.location.href = "/goals";
|
||||
} catch (error) {
|
||||
errorMessage.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRegister(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const errorMessage = document.getElementById("error-message");
|
||||
errorMessage.textContent = "";
|
||||
|
||||
const username = form.username.value.trim();
|
||||
const password = form.password.value;
|
||||
const confirmPassword = form["confirm-password"].value;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errorMessage.textContent = "Passwords do not match";
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
errorMessage.textContent = "Password must be at least 6 characters";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await post("/api/auth/register", { username, password });
|
||||
window.location.href = "/goals";
|
||||
} catch (error) {
|
||||
errorMessage.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
try {
|
||||
await post("/api/auth/logout");
|
||||
window.location.href = "/login";
|
||||
} catch (error) {
|
||||
console.error("Logout failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const loginForm = document.getElementById("login-form");
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener("submit", handleLogin);
|
||||
}
|
||||
|
||||
const registerForm = document.getElementById("register-form");
|
||||
if (registerForm) {
|
||||
registerForm.addEventListener("submit", handleRegister);
|
||||
}
|
||||
|
||||
const logoutBtn = document.getElementById("logout-btn");
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener("click", handleLogout);
|
||||
}
|
||||
|
||||
if (!document.body.classList.contains("auth-page")) {
|
||||
checkAuth();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
let goals = [];
|
||||
let deleteGoalId = null;
|
||||
|
||||
async function loadGoals() {
|
||||
try {
|
||||
goals = await get("/api/goals");
|
||||
renderGoals();
|
||||
} catch (error) {
|
||||
console.error("Failed to load goals:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGoals() {
|
||||
const container = document.getElementById("goals-list");
|
||||
|
||||
if (goals.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No goals yet</h3>
|
||||
<p>Create your first goal to get started!</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = goals.map(goal => `
|
||||
<div class="goal-card" data-goal-id="${goal.id}">
|
||||
<div class="goal-info">
|
||||
<div class="goal-title">${escapeHtml(goal.title)}</div>
|
||||
<div class="goal-status ${goal.activated ? 'active' : 'inactive'}">
|
||||
${goal.activated ? 'Active' : 'Inactive'}
|
||||
</div>
|
||||
</div>
|
||||
<div class="goal-actions">
|
||||
<button class="toggle-btn ${goal.activated ? 'deactivate' : 'activate'}"
|
||||
onclick="toggleGoal(${goal.id})">
|
||||
${goal.activated ? 'Deactivate' : 'Activate'}
|
||||
</button>
|
||||
<button class="edit-btn" onclick="editGoal(${goal.id})">Edit</button>
|
||||
<button class="delete-btn" onclick="confirmDelete(${goal.id})">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function openModal(goal = null) {
|
||||
const modal = document.getElementById("goal-modal");
|
||||
const modalTitle = document.getElementById("modal-title");
|
||||
const goalId = document.getElementById("goal-id");
|
||||
const goalTitle = document.getElementById("goal-title");
|
||||
const error = document.getElementById("goal-error");
|
||||
|
||||
error.textContent = "";
|
||||
|
||||
if (goal) {
|
||||
modalTitle.textContent = "Edit Goal";
|
||||
goalId.value = goal.doc_id;
|
||||
goalTitle.value = goal.title;
|
||||
} else {
|
||||
modalTitle.textContent = "Create Goal";
|
||||
goalId.value = "";
|
||||
goalTitle.value = "";
|
||||
}
|
||||
|
||||
modal.classList.add("active");
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById("goal-modal").classList.remove("active");
|
||||
}
|
||||
|
||||
async function handleGoalSubmit(event) {
|
||||
event.preventDefault();
|
||||
const error = document.getElementById("goal-error");
|
||||
error.textContent = "";
|
||||
|
||||
const goalId = document.getElementById("goal-id").value;
|
||||
const title = document.getElementById("goal-title").value.trim();
|
||||
|
||||
if (!title) {
|
||||
error.textContent = "Title is required";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (goalId) {
|
||||
await put(`/api/goals/${goalId}`, { title });
|
||||
} else {
|
||||
await post("/api/goals", { title });
|
||||
}
|
||||
closeModal();
|
||||
await loadGoals();
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleGoal(goalId) {
|
||||
try {
|
||||
await patch(`/api/goals/${goalId}/toggle`, {});
|
||||
await loadGoals();
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle goal:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function editGoal(goalId) {
|
||||
const goal = goals.find(g => g.id === goalId);
|
||||
if (goal) {
|
||||
openModal(goal);
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(goalId) {
|
||||
deleteGoalId = goalId;
|
||||
document.getElementById("delete-confirm-modal").classList.add("active");
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById("delete-confirm-modal").classList.remove("active");
|
||||
deleteGoalId = null;
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteGoalId) return;
|
||||
|
||||
try {
|
||||
await del(`/api/goals/${deleteGoalId}`);
|
||||
closeDeleteModal();
|
||||
await loadGoals();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete goal:", error);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("create-goal-btn").addEventListener("click", () => openModal());
|
||||
document.getElementById("modal-close").addEventListener("click", closeModal);
|
||||
document.getElementById("modal-cancel").addEventListener("click", closeModal);
|
||||
document.getElementById("goal-form").addEventListener("submit", handleGoalSubmit);
|
||||
document.getElementById("delete-modal-close").addEventListener("click", closeDeleteModal);
|
||||
document.getElementById("cancel-delete").addEventListener("click", closeDeleteModal);
|
||||
document.getElementById("confirm-delete").addEventListener("click", handleDelete);
|
||||
|
||||
loadGoals();
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
let goals = [];
|
||||
let tasks = [];
|
||||
let finishedTasks = [];
|
||||
let selectedGoalId = null;
|
||||
let selectedTaskId = null;
|
||||
let sortableInstance = null;
|
||||
|
||||
async function loadGoals() {
|
||||
try {
|
||||
goals = await get("/api/goals");
|
||||
const selector = document.getElementById("goal-selector");
|
||||
const activatedGoals = goals.filter(g => g.activated);
|
||||
|
||||
selector.innerHTML = activatedGoals.map(goal =>
|
||||
`<option value="${goal.id}">${escapeHtml(goal.title)}</option>`
|
||||
).join("");
|
||||
|
||||
if (activatedGoals.length > 0) {
|
||||
selectedGoalId = activatedGoals[0].id;
|
||||
selector.value = selectedGoalId;
|
||||
await loadTasks();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load goals:", error);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTasks() {
|
||||
if (!selectedGoalId) return;
|
||||
|
||||
try {
|
||||
const allTasks = await get(`/api/tasks?goal_id=${selectedGoalId}`);
|
||||
tasks = allTasks.filter(t => t.status !== "done");
|
||||
finishedTasks = allTasks.filter(t => t.status === "done");
|
||||
|
||||
renderTasks();
|
||||
renderFinishedTasks();
|
||||
initSortable();
|
||||
initScrollFocus();
|
||||
|
||||
const doingTask = tasks.find(t => t.status === "doing");
|
||||
if (doingTask) {
|
||||
scrollToTask(doingTask.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load tasks:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTasks() {
|
||||
const container = document.getElementById("tasks-list");
|
||||
|
||||
if (tasks.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="empty-state">
|
||||
<h3>No tasks yet</h3>
|
||||
<p>Create your first task for this goal!</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = tasks.map(task => `
|
||||
<div class="task-item ${task.status}" data-task-id="${task.id}" onclick="selectTask(${task.id})">
|
||||
<div class="task-title">${escapeHtml(task.title)}</div>
|
||||
<span class="task-status-badge ${task.status}">${task.status}</span>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderFinishedTasks() {
|
||||
const container = document.getElementById("finished-list");
|
||||
|
||||
if (finishedTasks.length === 0) {
|
||||
container.innerHTML = '<p style="color: #7f8c8d;">No completed tasks yet</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = finishedTasks.map(task => `
|
||||
<div class="finished-item" onclick="selectTask(${task.id})">
|
||||
<div class="finished-item-title">${escapeHtml(task.title)}</div>
|
||||
<div class="finished-item-time">Completed: ${formatTime(task.finished_time)}</div>
|
||||
</div>
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function initSortable() {
|
||||
const container = document.getElementById("tasks-list");
|
||||
|
||||
if (sortableInstance) {
|
||||
sortableInstance.destroy();
|
||||
}
|
||||
|
||||
sortableInstance = Sortable.create(container, {
|
||||
animation: 150,
|
||||
ghostClass: "sortable-ghost",
|
||||
chosenClass: "sortable-chosen",
|
||||
filter: ".task-item.done",
|
||||
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();
|
||||
} catch (error) {
|
||||
console.error("Failed to update order:", error);
|
||||
await loadTasks();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scrollToTask(taskId) {
|
||||
const taskElement = document.querySelector(`[data-task-id="${taskId}"]`);
|
||||
if (taskElement) {
|
||||
const scrollView = document.getElementById("scroll-view");
|
||||
const taskTop = taskElement.offsetTop;
|
||||
const scrollViewHeight = scrollView.clientHeight;
|
||||
const taskHeight = taskElement.offsetHeight;
|
||||
scrollView.scrollTop = taskTop - (scrollViewHeight / 2) + (taskHeight / 2);
|
||||
}
|
||||
}
|
||||
|
||||
function initScrollFocus() {
|
||||
const scrollView = document.getElementById("scroll-view");
|
||||
|
||||
scrollView.removeEventListener("scroll", handleScrollFocus);
|
||||
scrollView.addEventListener("scroll", handleScrollFocus);
|
||||
|
||||
handleScrollFocus();
|
||||
}
|
||||
|
||||
function handleScrollFocus() {
|
||||
const scrollView = document.getElementById("scroll-view");
|
||||
const taskItems = document.querySelectorAll(".task-item");
|
||||
|
||||
const scrollViewRect = scrollView.getBoundingClientRect();
|
||||
const focusCenter = scrollViewRect.top + scrollViewRect.height / 2;
|
||||
|
||||
let closestItem = null;
|
||||
let closestDistance = Infinity;
|
||||
|
||||
taskItems.forEach(item => {
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
const itemCenter = itemRect.top + itemRect.height / 2;
|
||||
const distance = Math.abs(itemCenter - focusCenter);
|
||||
|
||||
item.classList.remove("in-focus");
|
||||
|
||||
if (distance < closestDistance) {
|
||||
closestDistance = distance;
|
||||
closestItem = item;
|
||||
}
|
||||
});
|
||||
|
||||
if (closestItem) {
|
||||
closestItem.classList.add("in-focus");
|
||||
}
|
||||
}
|
||||
|
||||
function selectTask(taskId) {
|
||||
selectedTaskId = taskId;
|
||||
const task = [...tasks, ...finishedTasks].find(t => t.id === taskId);
|
||||
|
||||
if (!task) return;
|
||||
|
||||
document.getElementById("edit-task-title").value = task.title;
|
||||
document.getElementById("edit-task-desc").value = task.desc || "";
|
||||
document.getElementById("edit-task-status").value = task.status;
|
||||
document.getElementById("side-panel-error").textContent = "";
|
||||
|
||||
document.getElementById("side-panel").classList.add("active");
|
||||
}
|
||||
|
||||
function closeSidePanel() {
|
||||
document.getElementById("side-panel").classList.remove("active");
|
||||
selectedTaskId = null;
|
||||
}
|
||||
|
||||
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, ...finishedTasks].find(t => t.id === selectedTaskId);
|
||||
if (status !== currentTask?.status) {
|
||||
await patch(`/api/tasks/${selectedTaskId}/status`, { status });
|
||||
}
|
||||
|
||||
await loadTasks();
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
await loadTasks();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete task:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function openTaskModal() {
|
||||
document.getElementById("task-id").value = "";
|
||||
document.getElementById("task-title").value = "";
|
||||
document.getElementById("task-desc").value = "";
|
||||
document.getElementById("task-error").textContent = "";
|
||||
document.getElementById("task-modal-title").textContent = "Create Task";
|
||||
document.getElementById("task-modal").classList.add("active");
|
||||
}
|
||||
|
||||
function closeTaskModal() {
|
||||
document.getElementById("task-modal").classList.remove("active");
|
||||
}
|
||||
|
||||
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();
|
||||
await loadTasks();
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function formatTime(isoString) {
|
||||
if (!isoString) return "";
|
||||
const date = new Date(isoString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
document.getElementById("goal-selector").addEventListener("change", (e) => {
|
||||
selectedGoalId = parseInt(e.target.value);
|
||||
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);
|
||||
|
||||
loadGoals();
|
||||
});
|
||||
Reference in New Issue
Block a user