Files
oliver 9aac7eba2a check
2026-04-15 19:01:33 -03:00

721 lines
26 KiB
HTML

<!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,
cache: "no-store",
headers: {
"Content-Type": "application/json",
...authHeaders(),
},
};
if (body !== undefined) opts.body = JSON.stringify(body);
const url = method === "GET" ? API + "?_t=" + Date.now() : API;
const res = await fetch(url, 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 toBool = (v) => v === 1 || v === true || v === "1";
const done = toBool(item.done);
const name = item.name ?? item.title ?? "";
const today = toBool(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, "&amp;")
.replace(/"/g, "&quot;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
// ── 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>