todo
This commit is contained in:
@@ -0,0 +1,717 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Todo List</title>
|
||||||
|
<style>
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: "Segoe UI", system-ui, sans-serif;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Auth Modal */
|
||||||
|
#auth-overlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
z-index: 100;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
#auth-overlay.visible {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
#auth-box {
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 2rem;
|
||||||
|
width: 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
#auth-box h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #f87171;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#auth-box p {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #94a3b8;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
#auth-box input {
|
||||||
|
padding: 0.55rem 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #475569;
|
||||||
|
background: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
#auth-box input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #818cf8;
|
||||||
|
}
|
||||||
|
#auth-box button {
|
||||||
|
padding: 0.55rem;
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
#auth-box button:hover {
|
||||||
|
background: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card */
|
||||||
|
#app {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 820px;
|
||||||
|
background: #1e293b;
|
||||||
|
border: 1px solid #334155;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
#toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0.9rem 1.2rem;
|
||||||
|
border-bottom: 1px solid #334155;
|
||||||
|
background: #1e293b;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
#toolbar span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
#btn-refresh,
|
||||||
|
#btn-add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.45rem 0.9rem;
|
||||||
|
border-radius: 7px;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
transform 0.1s;
|
||||||
|
}
|
||||||
|
#btn-refresh {
|
||||||
|
background: #334155;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
#btn-refresh:hover {
|
||||||
|
background: #475569;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
#btn-add {
|
||||||
|
background: #4f46e5;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
#btn-add:hover {
|
||||||
|
background: #6366f1;
|
||||||
|
}
|
||||||
|
#btn-add:active,
|
||||||
|
#btn-refresh:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status */
|
||||||
|
#status {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.4rem 1.2rem;
|
||||||
|
min-height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: #64748b;
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
background: #162032;
|
||||||
|
transition: color 0.3s;
|
||||||
|
}
|
||||||
|
#status.ok {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
#status.error {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
#status.info {
|
||||||
|
color: #818cf8;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Spinner */
|
||||||
|
.spin {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border: 2px solid currentColor;
|
||||||
|
border-top-color: transparent;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
#table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
thead {
|
||||||
|
background: #162032;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
thead th {
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
color: #64748b;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
thead th.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
border-bottom: 1px solid #1e293b;
|
||||||
|
transition: background 0.12s;
|
||||||
|
}
|
||||||
|
tbody tr:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
tbody tr:hover {
|
||||||
|
background: #243049;
|
||||||
|
}
|
||||||
|
tbody tr.saving {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
td {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
td.center {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkbox */
|
||||||
|
input[type="checkbox"] {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
accent-color: #6366f1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Text inputs in table */
|
||||||
|
td input[type="text"],
|
||||||
|
td input[type="number"] {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #e2e8f0;
|
||||||
|
padding: 0.3rem 0.4rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
width: 100%;
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
|
}
|
||||||
|
td input[type="text"]:hover,
|
||||||
|
td input[type="number"]:hover {
|
||||||
|
border-color: #334155;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
td input[type="text"]:focus,
|
||||||
|
td input[type="number"]:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #6366f1;
|
||||||
|
background: #0f172a;
|
||||||
|
}
|
||||||
|
td.done-row input[type="text"] {
|
||||||
|
text-decoration: line-through;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Minutes column */
|
||||||
|
td input[type="number"] {
|
||||||
|
width: 70px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Delete button */
|
||||||
|
.btn-del {
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid #7f1d1d;
|
||||||
|
color: #f87171;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn-del:hover {
|
||||||
|
background: #7f1d1d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
#empty {
|
||||||
|
display: none;
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 1rem;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
#empty svg {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* New row highlight */
|
||||||
|
@keyframes highlight {
|
||||||
|
from {
|
||||||
|
background: #312e81;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody tr.new-row {
|
||||||
|
animation: highlight 1.2s ease-out;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Auth Modal -->
|
||||||
|
<div id="auth-overlay">
|
||||||
|
<div id="auth-box">
|
||||||
|
<h2>🔒 Authentication Required</h2>
|
||||||
|
<p>
|
||||||
|
A connection error occurred. Please enter your credentials.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="auth-user"
|
||||||
|
placeholder="Username"
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="auth-pass"
|
||||||
|
placeholder="Password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
/>
|
||||||
|
<button id="auth-submit">Connect</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main App -->
|
||||||
|
<div id="app">
|
||||||
|
<div id="toolbar">
|
||||||
|
<div style="display: flex; gap: 0.5rem; align-items: center">
|
||||||
|
<button id="btn-refresh">⟳ Refresh</button>
|
||||||
|
<span id="count"></span>
|
||||||
|
</div>
|
||||||
|
<button id="btn-add">+ New Todo</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status"></div>
|
||||||
|
|
||||||
|
<div id="table-wrap">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="center">Done</th>
|
||||||
|
<th>Name</th>
|
||||||
|
<th class="center">Today</th>
|
||||||
|
<th class="center">Minutes</th>
|
||||||
|
<th class="center">Delete</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="todo-body"></tbody>
|
||||||
|
</table>
|
||||||
|
<div id="empty">
|
||||||
|
<svg
|
||||||
|
width="48"
|
||||||
|
height="48"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
>
|
||||||
|
<rect x="3" y="3" width="18" height="18" rx="3" />
|
||||||
|
<path d="M9 12l2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
<div>No todos yet. Create one!</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API =
|
||||||
|
"https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/45d6f0be-6242-4cba-89ff-d1f0497591d1";
|
||||||
|
|
||||||
|
let credentials = null; // { user, pass }
|
||||||
|
let pendingSave = {}; // debounce timers per row id
|
||||||
|
let isFetching = false;
|
||||||
|
|
||||||
|
// ── Auth helpers ──────────────────────────────────────────────────────────
|
||||||
|
function authHeaders() {
|
||||||
|
if (!credentials) return {};
|
||||||
|
const b64 = btoa(credentials.user + ":" + credentials.pass);
|
||||||
|
return { Authorization: "Basic " + b64 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAuth(onSuccess) {
|
||||||
|
const overlay = document.getElementById("auth-overlay");
|
||||||
|
overlay.classList.add("visible");
|
||||||
|
document.getElementById("auth-user").focus();
|
||||||
|
document.getElementById("auth-submit").onclick = () => {
|
||||||
|
const user = document
|
||||||
|
.getElementById("auth-user")
|
||||||
|
.value.trim();
|
||||||
|
const pass = document.getElementById("auth-pass").value;
|
||||||
|
if (!user) return;
|
||||||
|
credentials = { user, pass };
|
||||||
|
overlay.classList.remove("visible");
|
||||||
|
onSuccess();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status bar ────────────────────────────────────────────────────────────
|
||||||
|
function setStatus(msg, type = "", spin = false) {
|
||||||
|
const el = document.getElementById("status");
|
||||||
|
el.className = type;
|
||||||
|
el.innerHTML =
|
||||||
|
(spin ? '<span class="spin"></span> ' : "") + msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API calls ─────────────────────────────────────────────────────────────
|
||||||
|
async function apiFetch(method, body) {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...authHeaders(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||||
|
const res = await fetch(API, opts);
|
||||||
|
if (res.status === 401 || res.status === 403)
|
||||||
|
throw new Error("AUTH");
|
||||||
|
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||||
|
const text = await res.text();
|
||||||
|
try {
|
||||||
|
return text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withAuth(fn) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
if (
|
||||||
|
e.message === "AUTH" ||
|
||||||
|
e.message.includes("NetworkError") ||
|
||||||
|
e.message.includes("Failed to fetch")
|
||||||
|
) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
showAuth(async () => {
|
||||||
|
try {
|
||||||
|
resolve(await fn());
|
||||||
|
} catch (e2) {
|
||||||
|
reject(e2);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Load todos ────────────────────────────────────────────────────────────
|
||||||
|
async function loadTodos() {
|
||||||
|
if (isFetching) return;
|
||||||
|
isFetching = true;
|
||||||
|
setStatus("Loading…", "info", true);
|
||||||
|
try {
|
||||||
|
const data = await withAuth(() => apiFetch("GET"));
|
||||||
|
const items = Array.isArray(data)
|
||||||
|
? data
|
||||||
|
: data.items ||
|
||||||
|
data.result ||
|
||||||
|
data.data ||
|
||||||
|
(data &&
|
||||||
|
typeof data === "object" &&
|
||||||
|
data.id !== undefined
|
||||||
|
? [data]
|
||||||
|
: []);
|
||||||
|
renderTable(items);
|
||||||
|
setStatus("Loaded " + items.length + " item(s)", "ok");
|
||||||
|
document.getElementById("count").textContent =
|
||||||
|
items.length + " items";
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("Error: " + e.message, "error");
|
||||||
|
} finally {
|
||||||
|
isFetching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render table ──────────────────────────────────────────────────────────
|
||||||
|
function renderTable(items) {
|
||||||
|
const tbody = document.getElementById("todo-body");
|
||||||
|
const empty = document.getElementById("empty");
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
|
||||||
|
if (!items.length) {
|
||||||
|
empty.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
empty.style.display = "none";
|
||||||
|
|
||||||
|
items.forEach((item) => tbody.appendChild(buildRow(item)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRow(item, isNew = false) {
|
||||||
|
const id = item.id ?? item._id ?? item.ID ?? "";
|
||||||
|
const done = !!item.done;
|
||||||
|
const name = item.name ?? item.title ?? "";
|
||||||
|
const today = !!(item.today ?? item.Today);
|
||||||
|
const mins = item.minutes ?? item.Minutes ?? item.mins ?? "";
|
||||||
|
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.id = id;
|
||||||
|
if (isNew) tr.classList.add("new-row");
|
||||||
|
if (done)
|
||||||
|
tr.querySelector && setTimeout(() => styleRow(tr, done), 0);
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td class="center">
|
||||||
|
<input type="checkbox" class="f-done" ${done ? "checked" : ""} title="Done"/>
|
||||||
|
</td>
|
||||||
|
<td class="${done ? "done-row" : ""}">
|
||||||
|
<input type="text" class="f-name" value="${escHtml(name)}" placeholder="Task name…"/>
|
||||||
|
</td>
|
||||||
|
<td class="center">
|
||||||
|
<input type="checkbox" class="f-today" ${today ? "checked" : ""} title="Today"/>
|
||||||
|
</td>
|
||||||
|
<td class="center">
|
||||||
|
<input type="number" class="f-mins" value="${escHtml(String(mins))}" min="0" placeholder="0"/>
|
||||||
|
</td>
|
||||||
|
<td class="center">
|
||||||
|
<button class="btn-del" title="Delete">🗑 Delete</button>
|
||||||
|
</td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Events: checkboxes → save immediately
|
||||||
|
tr.querySelector(".f-done").addEventListener("change", (e) => {
|
||||||
|
styleRow(tr, e.target.checked);
|
||||||
|
scheduleSave(tr);
|
||||||
|
});
|
||||||
|
tr.querySelector(".f-today").addEventListener("change", () =>
|
||||||
|
scheduleSave(tr),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Events: text inputs → debounce save
|
||||||
|
tr.querySelector(".f-name").addEventListener("input", () =>
|
||||||
|
scheduleSave(tr, 600),
|
||||||
|
);
|
||||||
|
tr.querySelector(".f-mins").addEventListener("input", () =>
|
||||||
|
scheduleSave(tr, 600),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
tr.querySelector(".btn-del").addEventListener("click", () =>
|
||||||
|
deleteTodo(tr),
|
||||||
|
);
|
||||||
|
|
||||||
|
return tr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function styleRow(tr, done) {
|
||||||
|
const nameTd = tr.querySelector("td:nth-child(2)");
|
||||||
|
if (done) nameTd.classList.add("done-row");
|
||||||
|
else nameTd.classList.remove("done-row");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Save (POST) ───────────────────────────────────────────────────────────
|
||||||
|
function scheduleSave(tr, delay = 0) {
|
||||||
|
const id = tr.dataset.id;
|
||||||
|
clearTimeout(pendingSave[id]);
|
||||||
|
pendingSave[id] = setTimeout(() => saveTodo(tr), delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTodo(tr) {
|
||||||
|
const id = tr.dataset.id;
|
||||||
|
const done = tr.querySelector(".f-done").checked;
|
||||||
|
const name = tr.querySelector(".f-name").value;
|
||||||
|
const today = tr.querySelector(".f-today").checked;
|
||||||
|
const mins = tr.querySelector(".f-mins").value;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
id,
|
||||||
|
done,
|
||||||
|
name,
|
||||||
|
today,
|
||||||
|
minutes: mins === "" ? null : Number(mins),
|
||||||
|
};
|
||||||
|
|
||||||
|
tr.classList.add("saving");
|
||||||
|
setStatus("Saving…", "info", true);
|
||||||
|
try {
|
||||||
|
await withAuth(() => apiFetch("POST", payload));
|
||||||
|
setStatus("Saved ✓", "ok");
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("Save failed: " + e.message, "error");
|
||||||
|
} finally {
|
||||||
|
tr.classList.remove("saving");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────────────
|
||||||
|
async function deleteTodo(tr) {
|
||||||
|
const id = tr.dataset.id;
|
||||||
|
const name = tr.querySelector(".f-name").value || "this item";
|
||||||
|
if (!confirm('Delete "' + name + '"?')) return;
|
||||||
|
|
||||||
|
setStatus("Deleting…", "info", true);
|
||||||
|
try {
|
||||||
|
await withAuth(() => apiFetch("DELETE", { id }));
|
||||||
|
tr.style.transition = "opacity 0.25s";
|
||||||
|
tr.style.opacity = "0";
|
||||||
|
setTimeout(() => {
|
||||||
|
tr.remove();
|
||||||
|
const count =
|
||||||
|
document.querySelectorAll("#todo-body tr").length;
|
||||||
|
document.getElementById("count").textContent =
|
||||||
|
count + " items";
|
||||||
|
if (!count)
|
||||||
|
document.getElementById("empty").style.display =
|
||||||
|
"block";
|
||||||
|
setStatus("Deleted ✓", "ok");
|
||||||
|
}, 260);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("Delete failed: " + e.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create new todo ───────────────────────────────────────────────────────
|
||||||
|
async function createTodo() {
|
||||||
|
const payload = {
|
||||||
|
done: false,
|
||||||
|
name: "",
|
||||||
|
today: false,
|
||||||
|
minutes: null,
|
||||||
|
};
|
||||||
|
setStatus("Creating…", "info", true);
|
||||||
|
try {
|
||||||
|
const res = await withAuth(() => apiFetch("POST", payload));
|
||||||
|
// Try to get id from response
|
||||||
|
const newItem = {
|
||||||
|
id: res.id ?? res._id ?? res.ID ?? "tmp_" + Date.now(),
|
||||||
|
done: false,
|
||||||
|
name: "",
|
||||||
|
today: false,
|
||||||
|
minutes: "",
|
||||||
|
};
|
||||||
|
const tbody = document.getElementById("todo-body");
|
||||||
|
document.getElementById("empty").style.display = "none";
|
||||||
|
const tr = buildRow(newItem, true);
|
||||||
|
tbody.prepend(tr);
|
||||||
|
tr.querySelector(".f-name").focus();
|
||||||
|
const count =
|
||||||
|
document.querySelectorAll("#todo-body tr").length;
|
||||||
|
document.getElementById("count").textContent =
|
||||||
|
count + " items";
|
||||||
|
setStatus("New todo created", "ok");
|
||||||
|
// Remove animation class after it plays
|
||||||
|
setTimeout(() => tr.classList.remove("new-row"), 1300);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus("Create failed: " + e.message, "error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Utils ─────────────────────────────────────────────────────────────────
|
||||||
|
function escHtml(s) {
|
||||||
|
return (s ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire up buttons ───────────────────────────────────────────────────────
|
||||||
|
document
|
||||||
|
.getElementById("btn-refresh")
|
||||||
|
.addEventListener("click", loadTodos);
|
||||||
|
document
|
||||||
|
.getElementById("btn-add")
|
||||||
|
.addEventListener("click", createTodo);
|
||||||
|
|
||||||
|
// Enter key in auth form
|
||||||
|
document
|
||||||
|
.getElementById("auth-pass")
|
||||||
|
.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter")
|
||||||
|
document.getElementById("auth-submit").click();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Initial load ──────────────────────────────────────────────────────────
|
||||||
|
loadTodos();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user