DRM / static /app.js
Codex
Release v0.2.0 weekly planner polish
0855e12
(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 = `
<div class="task-main">
<label class="check-wrap">
<input class="task-check" type="checkbox" ${task.completed ? "checked" : ""} data-toggle-task="${task.id}">
<span class="custom-check"></span>
</label>
<div class="task-copy">
<h4>${task.title}</h4>
<p class="meta-line">
<span class="meta-badge due-time" data-due-label></span>
<span class="meta-badge progress-text" data-progress-text></span>
</p>
</div>
</div>
<div class="progress-shell">
<div class="progress-bar" data-progress-bar></div>
</div>
${authenticated ? `<button class="task-delete" type="button" data-delete-task="${task.id}">删除</button>` : ""}
`;
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 = "<p>这里还没有任务</p><span>点击右上角的加号,给这个分类加上第一条提醒。</span>";
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();
})();