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