(function () { const state = { authenticated: document.body.dataset.authenticated === "true", nextPath: (window.__DRM_BOOTSTRAP__ && window.__DRM_BOOTSTRAP__.nextPath) || "/", loginRequired: !!(window.__DRM_BOOTSTRAP__ && window.__DRM_BOOTSTRAP__.loginRequired), }; const loginModal = document.getElementById("loginModal"); const taskModal = document.getElementById("taskModal"); const renameModal = document.getElementById("renameModal"); const toastStack = document.getElementById("toastStack"); const boardGrid = document.getElementById("boardGrid"); const loginForm = document.getElementById("loginForm"); const taskForm = document.getElementById("taskForm"); const renameForm = document.getElementById("renameForm"); const openLoginButton = document.getElementById("openLoginButton"); const logoutButton = document.getElementById("logoutButton"); const clockDisplay = document.getElementById("clockDisplay"); const dateDisplay = document.getElementById("dateDisplay"); const weekdayDisplay = document.getElementById("weekdayDisplay"); const lunarDisplay = document.getElementById("lunarDisplay"); const lunarData = [ 0x04bd8, 0x04ae0, 0x0a570, 0x054d5, 0x0d260, 0x0d950, 0x16554, 0x056a0, 0x09ad0, 0x055d2, 0x04ae0, 0x0a5b6, 0x0a4d0, 0x0d250, 0x1d255, 0x0b540, 0x0d6a0, 0x0ada2, 0x095b0, 0x14977, 0x04970, 0x0a4b0, 0x0b4b5, 0x06a50, 0x06d40, 0x1ab54, 0x02b60, 0x09570, 0x052f2, 0x04970, 0x06566, 0x0d4a0, 0x0ea50, 0x06e95, 0x05ad0, 0x02b60, 0x186e3, 0x092e0, 0x1c8d7, 0x0c950, 0x0d4a0, 0x1d8a6, 0x0b550, 0x056a0, 0x1a5b4, 0x025d0, 0x092d0, 0x0d2b2, 0x0a950, 0x0b557, 0x06ca0, 0x0b550, 0x15355, 0x04da0, 0x0a5d0, 0x14573, 0x052d0, 0x0a9a8, 0x0e950, 0x06aa0, 0x0aea6, 0x0ab50, 0x04b60, 0x0aae4, 0x0a570, 0x05260, 0x0f263, 0x0d950, 0x05b57, 0x056a0, 0x096d0, 0x04dd5, 0x04ad0, 0x0a4d0, 0x0d4d4, 0x0d250, 0x0d558, 0x0b540, 0x0b5a0, 0x195a6, 0x095b0, 0x049b0, 0x0a974, 0x0a4b0, 0x0b27a, 0x06a50, 0x06d40, 0x0af46, 0x0ab60, 0x09570, 0x04af5, 0x04970, 0x064b0, 0x074a3, 0x0ea50, 0x06b58, 0x05ac0, 0x0ab60, 0x096d5, 0x092e0, 0x0c960, 0x0d954, 0x0d4a0, 0x0da50, 0x07552, 0x056a0, 0x0abb7, 0x025d0, 0x092d0, 0x0cab5, 0x0a950, 0x0b4a0, 0x0baa4, 0x0ad50, 0x055d9, 0x04ba0, 0x0a5b0, 0x15176, 0x052b0, 0x0a930, 0x07954, 0x06aa0, 0x0ad50, 0x05b52, 0x04b60, 0x0a6e6, 0x0a4e0, 0x0d260, 0x0ea65, 0x0d530, 0x05aa0, 0x076a3, 0x096d0, 0x04bd7, 0x04ad0, 0x0a4d0, 0x1d0b6, 0x0d250, 0x0d520, 0x0dd45, 0x0b5a0, 0x056d0, 0x055b2, 0x049b0, 0x0a577, 0x0a4b0, 0x0aa50, 0x1b255, 0x06d20, 0x0ada0, 0x14b63, ]; function showToast(message, kind = "success") { const toast = document.createElement("div"); toast.className = `toast ${kind}`; toast.textContent = message; toastStack.appendChild(toast); window.setTimeout(() => { toast.remove(); }, 2600); } function openModal(modal) { if (modal) { modal.classList.add("is-open"); } } function closeModal(modal) { if (modal) { modal.classList.remove("is-open"); } } function requireAuth() { if (!state.authenticated) { openModal(loginModal); return false; } return true; } async function requestJSON(url, options = {}) { const response = await fetch(url, { headers: { "Content-Type": "application/json", }, ...options, }); const payload = await response.json().catch(() => ({ ok: false, error: "请求失败" })); if (!response.ok || !payload.ok) { throw new Error(payload.error || "请求失败"); } return payload; } function formatLocalDateTime(isoString) { const date = new Date(isoString); return new Intl.DateTimeFormat("zh-CN", { timeZone: "Asia/Shanghai", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }).format(date).replace(",", ""); } function clamp(number, min, max) { return Math.min(Math.max(number, min), max); } function mixColor(percent) { if (percent <= 50) { return "#73d883"; } if (percent <= 80) { return "#ffc857"; } return "#ff6b5c"; } function renderProgress() { const now = new Date(); document.querySelectorAll(".task-card").forEach((card) => { const createdAt = new Date(card.dataset.createdAt); const dueAt = new Date(card.dataset.dueAt); const completed = card.dataset.completed === "true"; const total = dueAt.getTime() - createdAt.getTime(); const elapsed = now.getTime() - createdAt.getTime(); const progress = completed || total <= 0 ? 100 : clamp((elapsed / total) * 100, 0, 100); const dueLabel = card.querySelector("[data-due-label]"); const progressText = card.querySelector("[data-progress-text]"); const progressBar = card.querySelector("[data-progress-bar]"); if (dueLabel) { dueLabel.textContent = `截止 ${formatLocalDateTime(card.dataset.dueAt)}`; } if (progressText) { progressText.textContent = completed ? "已完成" : `已过去 ${progress.toFixed(0)}%`; } if (progressBar) { progressBar.style.width = `${progress}%`; progressBar.style.background = completed ? "linear-gradient(90deg, #66d0ff 0%, #7be7ea 100%)" : mixColor(progress); } }); } function getBit(year, month) { return (lunarData[year - 1900] & (0x10000 >> month)) !== 0 ? 1 : 0; } function leapMonth(year) { return lunarData[year - 1900] & 0xf; } function leapDays(year) { if (leapMonth(year)) { return (lunarData[year - 1900] & 0x10000) ? 30 : 29; } return 0; } function monthDays(year, month) { return getBit(year, month) ? 30 : 29; } function lunarYearDays(year) { let sum = 348; for (let i = 0x8000; i > 0x8; i >>= 1) { sum += (lunarData[year - 1900] & i) ? 1 : 0; } return sum + leapDays(year); } function solarToLunar(date) { const baseDate = new Date(Date.UTC(1900, 0, 31)); const utcDate = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); let offset = Math.floor((utcDate - baseDate) / 86400000); let year; for (year = 1900; year < 2101 && offset > 0; year += 1) { const temp = lunarYearDays(year); if (offset < temp) { break; } offset -= temp; } let month = 1; let isLeap = false; const leap = leapMonth(year); while (month <= 12 && offset >= 0) { let temp; if (leap > 0 && month === leap + 1 && !isLeap) { month -= 1; isLeap = true; temp = leapDays(year); } else { temp = monthDays(year, month); } if (offset < temp) { break; } offset -= temp; if (isLeap && month === leap) { isLeap = false; } month += 1; } return { year, month, day: offset + 1, isLeap, }; } function formatLunar(date) { const lunar = solarToLunar(date); const monthNames = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"]; const dayNames = [ "初一", "初二", "初三", "初四", "初五", "初六", "初七", "初八", "初九", "初十", "十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十", "廿一", "廿二", "廿三", "廿四", "廿五", "廿六", "廿七", "廿八", "廿九", "三十", ]; const prefix = lunar.isLeap ? "闰" : ""; return `农历 ${prefix}${monthNames[lunar.month - 1]}月${dayNames[lunar.day - 1]}`; } function getBeijingDate() { const parts = new Intl.DateTimeFormat("en-CA", { timeZone: "Asia/Shanghai", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false, }).formatToParts(new Date()); const map = {}; parts.forEach((part) => { if (part.type !== "literal") { map[part.type] = part.value; } }); return { year: Number(map.year), month: Number(map.month), day: Number(map.day), hour: map.hour, minute: map.minute, second: map.second, }; } function renderClock() { const now = getBeijingDate(); clockDisplay.textContent = `${now.hour}:${now.minute}:${now.second}`; dateDisplay.textContent = `${now.year} 年 ${String(now.month).padStart(2, "0")} 月 ${String(now.day).padStart(2, "0")} 日`; const currentDate = new Date(now.year, now.month - 1, now.day); const weekdays = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"]; weekdayDisplay.textContent = weekdays[currentDate.getDay()]; lunarDisplay.textContent = formatLunar(currentDate); } function taskTemplate(task, authenticated) { const card = document.createElement("article"); card.className = `task-card ${task.completed ? "task-complete" : ""}`; card.dataset.taskId = task.id; card.dataset.createdAt = task.created_at; card.dataset.dueAt = task.due_at; card.dataset.completed = task.completed ? "true" : "false"; card.innerHTML = `

${task.title}

${authenticated ? `` : ""} `; return card; } function ensureEmptyState(column) { const list = column.querySelector(".task-list"); if (!list) { return; } const cards = list.querySelectorAll(".task-card"); const empty = list.querySelector(".empty-state"); if (!cards.length && !empty) { const block = document.createElement("div"); block.className = "empty-state"; block.innerHTML = "

这里还没有任务

点击右上角的加号,给这个分类加上第一条提醒。"; list.appendChild(block); } if (cards.length && empty) { empty.remove(); } } function setDefaultDueAt() { const input = document.getElementById("taskDueAt"); const now = getBeijingDate(); const rounded = new Date(now.year, now.month - 1, now.day, Number(now.hour) + 1, 0, 0); const yyyy = rounded.getFullYear(); const mm = String(rounded.getMonth() + 1).padStart(2, "0"); const dd = String(rounded.getDate()).padStart(2, "0"); const hh = String(rounded.getHours()).padStart(2, "0"); input.value = `${yyyy}-${mm}-${dd}T${hh}:00`; } async function handleLogin(event) { event.preventDefault(); const password = document.getElementById("loginPassword").value; try { await requestJSON("/api/login", { method: "POST", body: JSON.stringify({ password, next: state.nextPath, }), }); showToast("登录成功,已解锁编辑权限"); window.location.href = state.nextPath || "/"; } catch (error) { showToast(error.message, "error"); } } async function handleLogout() { try { await requestJSON("/api/logout", { method: "POST", body: JSON.stringify({}), }); window.location.reload(); } catch (error) { showToast(error.message, "error"); } } async function handleTaskSubmit(event) { event.preventDefault(); if (!requireAuth()) { return; } const categoryId = document.getElementById("taskCategoryId").value; const title = document.getElementById("taskName").value.trim(); const dueAt = document.getElementById("taskDueAt").value; try { const payload = await requestJSON(`/api/categories/${categoryId}/tasks`, { method: "POST", body: JSON.stringify({ title, due_at: dueAt, }), }); const column = document.querySelector(`[data-category-id="${categoryId}"]`); const list = column.querySelector(".task-list"); const card = taskTemplate(payload.task, state.authenticated); list.prepend(card); ensureEmptyState(column); renderProgress(); closeModal(taskModal); taskForm.reset(); setDefaultDueAt(); showToast("任务已添加"); } catch (error) { showToast(error.message, "error"); } } async function handleRenameSubmit(event) { event.preventDefault(); if (!requireAuth()) { return; } const categoryId = document.getElementById("renameCategoryId").value; const name = document.getElementById("renameCategoryName").value.trim(); try { await requestJSON(`/api/categories/${categoryId}`, { method: "PATCH", body: JSON.stringify({ name }), }); const column = document.querySelector(`[data-category-id="${categoryId}"]`); if (column) { const title = column.querySelector(".column-title"); if (title) { title.textContent = name; } } closeModal(renameModal); showToast("分类名称已更新"); } catch (error) { showToast(error.message, "error"); } } async function toggleTask(taskId, checked, input) { if (!requireAuth()) { input.checked = !checked; return; } try { await requestJSON(`/api/tasks/${taskId}`, { method: "PATCH", body: JSON.stringify({ completed: checked }), }); const card = document.querySelector(`[data-task-id="${taskId}"]`); if (card) { card.dataset.completed = checked ? "true" : "false"; card.classList.toggle("task-complete", checked); renderProgress(); } showToast(checked ? "任务已完成" : "任务已恢复"); } catch (error) { input.checked = !checked; showToast(error.message, "error"); } } async function deleteTask(taskId) { if (!requireAuth()) { return; } try { await requestJSON(`/api/tasks/${taskId}`, { method: "DELETE", body: JSON.stringify({}), }); const card = document.querySelector(`[data-task-id="${taskId}"]`); const column = card && card.closest(".todo-column"); if (card) { card.remove(); } if (column) { ensureEmptyState(column); } showToast("任务已删除"); } catch (error) { showToast(error.message, "error"); } } function bindBoardEvents() { boardGrid.addEventListener("click", (event) => { const addButton = event.target.closest("[data-open-add]"); if (addButton) { if (!requireAuth()) { return; } document.getElementById("taskCategoryId").value = addButton.dataset.openAdd; const column = addButton.closest(".todo-column"); const title = column ? column.querySelector(".column-title").textContent : "添加提醒"; document.getElementById("taskModalTitle").textContent = `添加到 ${title}`; openModal(taskModal); setDefaultDueAt(); return; } const menuTrigger = event.target.closest("[data-menu-trigger]"); if (menuTrigger) { const panel = document.querySelector(`[data-menu-panel="${menuTrigger.dataset.menuTrigger}"]`); document.querySelectorAll(".menu-panel").forEach((item) => { if (item !== panel) { item.classList.remove("is-open"); } }); if (panel) { panel.classList.toggle("is-open"); } return; } const renameButton = event.target.closest("[data-rename-category]"); if (renameButton) { if (!requireAuth()) { return; } const categoryId = renameButton.dataset.renameCategory; const column = document.querySelector(`[data-category-id="${categoryId}"]`); const currentName = column ? column.querySelector(".column-title").textContent.trim() : ""; document.getElementById("renameCategoryId").value = categoryId; document.getElementById("renameCategoryName").value = currentName; closeMenus(); openModal(renameModal); return; } const deleteButton = event.target.closest("[data-delete-task]"); if (deleteButton) { deleteTask(deleteButton.dataset.deleteTask); } }); boardGrid.addEventListener("change", (event) => { const checkbox = event.target.closest("[data-toggle-task]"); if (checkbox) { toggleTask(checkbox.dataset.toggleTask, checkbox.checked, checkbox); } }); } function closeMenus() { document.querySelectorAll(".menu-panel.is-open").forEach((panel) => { panel.classList.remove("is-open"); }); } document.addEventListener("click", (event) => { if (!event.target.closest(".menu-wrap")) { closeMenus(); } const closeTarget = event.target.closest("[data-close-modal]"); if (closeTarget) { const modal = document.getElementById(closeTarget.dataset.closeModal); closeModal(modal); } if (event.target.classList.contains("modal-backdrop")) { closeModal(event.target); } }); loginForm.addEventListener("submit", handleLogin); taskForm.addEventListener("submit", handleTaskSubmit); renameForm.addEventListener("submit", handleRenameSubmit); bindBoardEvents(); if (openLoginButton) { openLoginButton.addEventListener("click", () => openModal(loginModal)); } if (logoutButton) { logoutButton.addEventListener("click", handleLogout); } if (!state.authenticated && state.loginRequired) { openModal(loginModal); } renderClock(); renderProgress(); window.setInterval(renderClock, 1000); window.setInterval(renderProgress, 30000); setDefaultDueAt(); })();