523 lines
22 KiB
JavaScript
523 lines
22 KiB
JavaScript
const columns = [
|
|
{ key: "offen", state: "Backlog", label: "Offen", color: "var(--st-queue)" },
|
|
{ key: "inProgress", state: "In progress", label: "In Bearbeitung", color: "var(--st-work)" },
|
|
{ key: "review", state: "Review", label: "Review", color: "#fb923c" },
|
|
{ key: "done", state: "Done", label: "Erledigt", color: "var(--st-work)" },
|
|
{ key: "blocked", state: "Blocked", label: "Blockiert", color: "var(--st-block)", blocked: true }
|
|
];
|
|
const states = columns.map((column) => column.state);
|
|
const priorities = ["High", "Medium", "Low"];
|
|
const assignees = [
|
|
{ value: "luna", label: "Luna" },
|
|
{ value: "maxi", label: "Maxi" }
|
|
];
|
|
|
|
const app = document.querySelector("#app");
|
|
const state = {
|
|
user: null,
|
|
tasks: [],
|
|
settings: null,
|
|
openClaw: null,
|
|
view: "board",
|
|
dialogMode: null,
|
|
selectedTaskId: null,
|
|
draggedTaskId: null,
|
|
dragOverKey: null,
|
|
toast: null,
|
|
error: ""
|
|
};
|
|
|
|
async function api(path, options = {}) {
|
|
const response = await fetch(path, {
|
|
credentials: "same-origin",
|
|
headers: { "content-type": "application/json", ...(options.headers || {}) },
|
|
...options
|
|
});
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
|
return data;
|
|
}
|
|
|
|
async function init() {
|
|
try {
|
|
const session = await api("/api/session");
|
|
state.user = session.user;
|
|
await Promise.all([loadTasks(), loadSettings()]);
|
|
renderApp();
|
|
} catch {
|
|
renderLogin();
|
|
}
|
|
}
|
|
|
|
function renderLogin() {
|
|
app.innerHTML = `
|
|
<section class="login-shell">
|
|
<form class="login-card" id="login-form">
|
|
<div class="login-brand">
|
|
<div class="brand-mark">MC</div>
|
|
<div><strong>MAXI</strong><span>Mission Control</span></div>
|
|
</div>
|
|
<div class="login-heading">
|
|
<span class="eyebrow">AUTHENTIFIZIERUNG</span>
|
|
<h1>Anmelden</h1>
|
|
<p>Maxis geschuetzte Kommandozentrale fuer Aufgaben und OpenClaw-Status.</p>
|
|
</div>
|
|
<label><span>E-Mail</span><input name="email" type="email" autocomplete="username" required autofocus></label>
|
|
<label><span>Passwort</span><input name="password" type="password" autocomplete="current-password" required></label>
|
|
<button type="submit">Anmelden</button>
|
|
<p class="login-error" id="login-error" role="alert"></p>
|
|
<footer>Gesicherte Sitzung - Maxi Branding bleibt erhalten.</footer>
|
|
</form>
|
|
</section>
|
|
`;
|
|
document.querySelector("#login-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const form = new FormData(event.currentTarget);
|
|
try {
|
|
const result = await api("/api/login", {
|
|
method: "POST",
|
|
body: JSON.stringify({ email: form.get("email"), password: form.get("password") })
|
|
});
|
|
state.user = result.user;
|
|
await Promise.all([loadTasks(), loadSettings()]);
|
|
renderApp();
|
|
} catch (error) {
|
|
document.querySelector("#login-error").textContent = error.message;
|
|
}
|
|
});
|
|
}
|
|
|
|
async function loadTasks() {
|
|
const result = await api("/api/tasks");
|
|
state.tasks = result.tasks.map(normalizeTask);
|
|
}
|
|
|
|
async function loadSettings() {
|
|
const result = await api("/api/settings");
|
|
state.settings = result.settings;
|
|
state.openClaw = result.openClaw;
|
|
}
|
|
|
|
function normalizeTask(task) {
|
|
return {
|
|
...task,
|
|
state: task.state === "In Progress" ? "In progress" : task.state,
|
|
priority: task.priority === "Normal" ? "Medium" : task.priority === "Urgent" ? "High" : task.priority,
|
|
assignee: normalizeAssignee(task.assignee)
|
|
};
|
|
}
|
|
|
|
function renderApp() {
|
|
app.innerHTML = `
|
|
<section class="shell ${state.view === "board" ? "board-mode" : "settings-mode"}">
|
|
<aside class="sidebar">
|
|
<div class="brand">
|
|
<div class="brand-mark">MC</div>
|
|
<div class="brand-copy"><strong>MAXI</strong><span>mc.texisoft.com</span></div>
|
|
</div>
|
|
<nav class="nav">
|
|
<button data-view="board" class="${state.view === "board" ? "active" : ""}" title="Task Board"><span class="nav-label">Task Board</span><i>${state.tasks.length}</i></button>
|
|
<button data-view="settings" class="${state.view === "settings" ? "active" : ""}" title="Settings"><span class="nav-label">Settings</span></button>
|
|
</nav>
|
|
<div class="sidebar-bottom">
|
|
<button id="logout">Abmelden</button>
|
|
<div class="owner">
|
|
<div class="avatar">MX</div>
|
|
<div><strong>${escapeHtml(state.user?.name || "Maxi")}</strong><span>${escapeHtml(state.user?.role || "Admin")}</span></div>
|
|
</div>
|
|
</div>
|
|
</aside>
|
|
<main>
|
|
<header class="topbar">
|
|
<button class="mobile-menu">Menu</button>
|
|
<div class="search">Mission Control <kbd>/</kbd></div>
|
|
<div class="top-actions">
|
|
<span class="connection ${state.openClaw?.ok ? "live" : "preview"}"><span class="status-dot ${state.openClaw?.ok ? "online" : "offline"}"></span>OpenClaw ${state.openClaw?.ok ? "live" : "pruefen"}</span>
|
|
<button class="refresh" id="refresh-board">Refresh</button>
|
|
</div>
|
|
</header>
|
|
<section class="content">${state.view === "settings" ? settingsView() : boardView()}</section>
|
|
</main>
|
|
</section>
|
|
${toastView()}
|
|
${createModal()}
|
|
${detailModal()}
|
|
`;
|
|
|
|
document.querySelectorAll("[data-view]").forEach((button) => {
|
|
button.addEventListener("click", async () => {
|
|
state.view = button.dataset.view;
|
|
if (state.view === "settings") await loadSettings();
|
|
renderApp();
|
|
});
|
|
});
|
|
document.querySelector("#logout").addEventListener("click", async () => {
|
|
await api("/api/logout", { method: "POST" });
|
|
state.user = null;
|
|
state.toast = null;
|
|
renderLogin();
|
|
});
|
|
document.querySelector("#refresh-board").addEventListener("click", async () => {
|
|
await Promise.all([loadTasks(), loadSettings()]);
|
|
renderApp();
|
|
showToast("Aktualisiert");
|
|
});
|
|
if (state.view === "board") bindBoard();
|
|
if (state.view === "settings") bindSettings();
|
|
}
|
|
|
|
function boardView() {
|
|
return `
|
|
<div class="board-wrap">
|
|
<div class="board-header">
|
|
<div>
|
|
<h1><span class="grad-text">Aufgaben</span></h1>
|
|
<p class="board-subtitle">Task Board - Uebersicht aller Arbeitspakete</p>
|
|
<div class="board-live-row">
|
|
<span class="live-pill ${state.openClaw?.ok ? "live-pill-connected" : "live-pill-degraded"}">${state.openClaw?.ok ? "Live" : "Polling"}</span>
|
|
<span class="live-meta">${state.tasks.length} Aufgaben</span>
|
|
</div>
|
|
</div>
|
|
<div class="board-header-actions">
|
|
<button class="create-btn" id="new-task"><span class="plus-icon">+</span>Neue Aufgabe</button>
|
|
</div>
|
|
</div>
|
|
<div class="board-columns">
|
|
${columns.map(column).join("")}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function column(columnDef) {
|
|
const tasks = state.tasks.filter((task) => task.state === columnDef.state);
|
|
return `
|
|
<div class="col ${columnDef.blocked ? "col-blocked" : ""} ${state.dragOverKey === columnDef.key ? "drag-over" : ""}" data-state="${columnDef.state}" data-column="${columnDef.key}">
|
|
<div class="col-header">
|
|
<span class="col-icon" style="background:${columnDef.color};box-shadow:0 0 0 2px color-mix(in srgb, ${columnDef.color}, transparent 72%);"></span>
|
|
<span class="col-name">${columnDef.label}</span>
|
|
<span class="col-count">${tasks.length}</span>
|
|
</div>
|
|
<div class="col-cards">
|
|
${tasks.map(taskCard).join("") || `<div class="empty-col">${columnDef.blocked ? "Keine Blockierer" : "Keine Aufgaben"}</div>`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function taskCard(task) {
|
|
const blockedClass = task.state === "Blocked" ? "card-blocked" : "";
|
|
return `
|
|
<button type="button" class="card ${blockedClass}" data-card="${task.id}" draggable="true">
|
|
<div class="card-top">
|
|
<span class="prio-badge" style="color:${priorityColor(task.priority)};border-color:${priorityColor(task.priority)}">${priorityLabel(task.priority)}</span>
|
|
<span class="assignee ${assigneeClass(task.assignee)}">${assigneeLabel(task.assignee)}</span>
|
|
</div>
|
|
<div class="card-title">${escapeHtml(task.title)}</div>
|
|
${task.detail ? `<div class="card-preview">${escapeHtml(task.detail)}</div>` : ""}
|
|
<div class="card-meta">Update ${relativeTime(task.updatedAt || task.createdAt)}</div>
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
function createModal() {
|
|
if (state.dialogMode !== "create") return "";
|
|
return `
|
|
<div class="modal-overlay" id="modal-overlay">
|
|
<div class="modal-card">
|
|
<div class="modal-header"><h2>Neue Aufgabe</h2><button class="modal-close" type="button" id="close-modal">×</button></div>
|
|
<form class="modal-form" id="create-form">
|
|
<div class="field"><label for="task-title">Titel <span class="req">*</span></label><input id="task-title" name="title" class="field-input" placeholder="Aufgabe beschreiben..." required autofocus></div>
|
|
<div class="field"><label for="task-detail">Beschreibung</label><textarea id="task-detail" name="detail" class="field-input field-textarea" rows="3" placeholder="Details zur Aufgabe..."></textarea></div>
|
|
<div class="field-row">
|
|
<div class="field"><label for="task-priority">Prioritaet</label><select id="task-priority" name="priority" class="field-input field-select">${priorities.map((p) => `<option ${p === "Medium" ? "selected" : ""}>${p}</option>`).join("")}</select></div>
|
|
<div class="field"><label for="task-assignee">Zugewiesen an</label><select id="task-assignee" name="assignee" class="field-input field-select">${assignees.map((a) => `<option value="${a.value}" ${a.value === "luna" ? "selected" : ""}>${a.label}</option>`).join("")}</select></div>
|
|
</div>
|
|
<p class="form-error" id="form-error"></p>
|
|
<div class="modal-actions"><button type="button" class="btn-cancel" id="cancel-modal">Abbrechen</button><button type="submit" class="btn-submit">Aufgabe erstellen</button></div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function detailModal() {
|
|
if (state.dialogMode !== "detail") return "";
|
|
const task = selectedTask();
|
|
if (!task) return "";
|
|
return `
|
|
<div class="detail-overlay" id="detail-overlay">
|
|
<aside class="detail-panel">
|
|
<div class="detail-topbar"><div class="detail-breadcrumb">Task Board / ${stateLabel(task.state)}</div><button class="detail-close" id="close-detail" type="button">x</button></div>
|
|
<div class="detail-content">
|
|
<section class="detail-main">
|
|
<div class="detail-title-block">
|
|
<span class="detail-state-pill ${statusTone(task.state)}">${stateLabel(task.state)}</span>
|
|
<input class="detail-title-input" name="title" form="detail-form" maxlength="240" value="${escapeAttr(task.title)}">
|
|
<div class="detail-meta-row"><span>#${escapeHtml(task.id.slice(0, 8))}</span><span>Aktualisiert ${formatDate(task.updatedAt, true)}</span><span>Erstellt ${formatDate(task.createdAt)}</span></div>
|
|
</div>
|
|
<div class="detail-section"><div class="detail-section-header">Beschreibung</div><textarea class="detail-textarea" name="detail" form="detail-form" rows="8" placeholder="Mehr Kontext, Akzeptanzkriterien oder Notizen...">${escapeHtml(task.detail || "")}</textarea></div>
|
|
<div class="detail-grid">
|
|
<section class="detail-section"><div class="detail-section-header">Unteraufgaben</div><div class="detail-empty">Noch keine Unteraufgaben verknuepft.</div></section>
|
|
<section class="detail-section"><div class="detail-section-header">Aktivitaet</div><div class="activity-list"><article class="activity-item"><div class="activity-dot"></div><div><div class="activity-message">Task in Maxi Mission Control aktualisiert.</div><div class="activity-time">${formatDate(task.updatedAt, true)}</div></div></article></div></section>
|
|
</div>
|
|
</section>
|
|
<aside class="detail-sidebar">
|
|
<form id="detail-form" class="sidebar-card">
|
|
<div class="sidebar-heading">Eigenschaften</div>
|
|
<label class="sidebar-field"><span>Status</span><select name="state" class="field-input field-select slim">${states.map((s) => `<option value="${s}" ${s === task.state ? "selected" : ""}>${stateLabel(s)}</option>`).join("")}</select></label>
|
|
<label class="sidebar-field"><span>Prioritaet</span><select name="priority" class="field-input field-select slim">${priorities.map((p) => `<option ${p === task.priority ? "selected" : ""}>${p}</option>`).join("")}</select></label>
|
|
<label class="sidebar-field"><span>Zustaendig</span><select name="assignee" class="field-input field-select slim">${assignees.map((a) => `<option value="${a.value}" ${a.value === normalizeAssignee(task.assignee) ? "selected" : ""}>${a.label}</option>`).join("")}</select></label>
|
|
</form>
|
|
<section class="sidebar-card subtle"><div class="sidebar-heading">Snapshot</div><dl class="snapshot-list"><div><dt>Quelle</dt><dd>Maxi</dd></div><div><dt>Prioritaet</dt><dd>${escapeHtml(task.priority)}</dd></div><div><dt>Zustaendig</dt><dd>${assigneeLabel(task.assignee)}</dd></div></dl></section>
|
|
<p class="detail-flash error" id="detail-error"></p>
|
|
<div class="detail-actions"><button type="button" class="btn-cancel" id="cancel-detail">Schliessen</button><button type="button" class="btn-submit" id="save-detail">Speichern</button><button type="button" class="btn-danger" id="delete-detail">Loeschen</button></div>
|
|
</aside>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function bindBoard() {
|
|
document.querySelector("#new-task").addEventListener("click", () => {
|
|
state.dialogMode = "create";
|
|
renderApp();
|
|
});
|
|
document.querySelectorAll(".col").forEach((columnEl) => {
|
|
columnEl.addEventListener("dragover", (event) => {
|
|
event.preventDefault();
|
|
state.dragOverKey = columnEl.dataset.column;
|
|
columnEl.classList.add("drag-over");
|
|
});
|
|
columnEl.addEventListener("dragleave", () => columnEl.classList.remove("drag-over"));
|
|
columnEl.addEventListener("drop", async (event) => {
|
|
event.preventDefault();
|
|
columnEl.classList.remove("drag-over");
|
|
const id = event.dataTransfer?.getData("text/plain") || state.draggedTaskId;
|
|
state.dragOverKey = null;
|
|
if (!id) return;
|
|
await moveTask(id, columnEl.dataset.state);
|
|
});
|
|
});
|
|
document.querySelectorAll("[data-card]").forEach((card) => {
|
|
card.addEventListener("dragstart", (event) => {
|
|
state.draggedTaskId = card.dataset.card;
|
|
event.dataTransfer.effectAllowed = "move";
|
|
event.dataTransfer.setData("text/plain", card.dataset.card);
|
|
card.classList.add("dragging");
|
|
});
|
|
card.addEventListener("dragend", () => {
|
|
state.draggedTaskId = null;
|
|
card.classList.remove("dragging");
|
|
});
|
|
card.addEventListener("click", () => {
|
|
state.selectedTaskId = card.dataset.card;
|
|
state.dialogMode = "detail";
|
|
renderApp();
|
|
});
|
|
});
|
|
bindCreateModal();
|
|
bindDetailModal();
|
|
}
|
|
|
|
function bindCreateModal() {
|
|
const form = document.querySelector("#create-form");
|
|
if (!form) return;
|
|
document.querySelector("#close-modal").addEventListener("click", closeModal);
|
|
document.querySelector("#cancel-modal").addEventListener("click", closeModal);
|
|
document.querySelector("#modal-overlay").addEventListener("click", (event) => {
|
|
if (event.target.id === "modal-overlay") closeModal();
|
|
});
|
|
form.addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const data = Object.fromEntries(new FormData(form));
|
|
try {
|
|
await api("/api/tasks", { method: "POST", body: JSON.stringify({ ...data, state: "Backlog" }) });
|
|
closeModal();
|
|
await loadTasks();
|
|
renderApp();
|
|
showToast("Aufgabe erstellt");
|
|
} catch (error) {
|
|
document.querySelector("#form-error").textContent = error.message;
|
|
showToast(error.message, "error");
|
|
}
|
|
});
|
|
}
|
|
|
|
function bindDetailModal() {
|
|
const form = document.querySelector("#detail-form");
|
|
if (!form) return;
|
|
document.querySelector("#close-detail").addEventListener("click", closeModal);
|
|
document.querySelector("#cancel-detail").addEventListener("click", closeModal);
|
|
document.querySelector("#detail-overlay").addEventListener("click", (event) => {
|
|
if (event.target.id === "detail-overlay") closeModal();
|
|
});
|
|
document.querySelector("#save-detail").addEventListener("click", async () => {
|
|
const data = Object.fromEntries(new FormData(form));
|
|
data.title = document.querySelector(".detail-title-input").value;
|
|
data.detail = document.querySelector(".detail-textarea").value;
|
|
try {
|
|
await api(`/api/tasks/${state.selectedTaskId}`, { method: "PATCH", body: JSON.stringify(data) });
|
|
closeModal();
|
|
await loadTasks();
|
|
renderApp();
|
|
showToast("Aufgabe gespeichert");
|
|
} catch (error) {
|
|
document.querySelector("#detail-error").textContent = error.message;
|
|
showToast(error.message, "error");
|
|
}
|
|
});
|
|
document.querySelector("#delete-detail").addEventListener("click", async () => {
|
|
await api(`/api/tasks/${state.selectedTaskId}`, { method: "DELETE" });
|
|
closeModal();
|
|
await loadTasks();
|
|
renderApp();
|
|
showToast("Aufgabe geloescht");
|
|
});
|
|
}
|
|
|
|
async function moveTask(id, nextState) {
|
|
await api(`/api/tasks/${id}`, { method: "PATCH", body: JSON.stringify({ state: nextState }) });
|
|
await loadTasks();
|
|
renderApp();
|
|
showToast(`Status: ${stateLabel(nextState)}`);
|
|
}
|
|
|
|
function settingsView() {
|
|
const openClaw = state.openClaw || {};
|
|
return `
|
|
<div class="settings-wrap">
|
|
<div class="page-heading">
|
|
<div><span class="eyebrow">MAXI</span><h1>Settings</h1><p>Admin-Konfiguration und Maxi-OpenClaw-Verbindung.</p></div>
|
|
<button class="refresh" id="refresh-settings">Aktualisieren</button>
|
|
</div>
|
|
<section class="settings-grid">
|
|
<article class="module-card">
|
|
<div class="module-card-head"><span class="eyebrow">OPENCLAW</span><span class="badge ${openClaw.ok ? "positive" : "warning"}">${openClaw.ok ? "Live" : "Check"}</span></div>
|
|
<h3>Gateway</h3>
|
|
<p>${escapeHtml(openClaw.baseUrl || state.settings?.openClawBaseUrl || "")}</p>
|
|
<p>Health ${escapeHtml(String(openClaw.healthStatus || openClaw.error || "unbekannt"))} - ${escapeHtml(String(openClaw.latencyMs || 0))} ms</p>
|
|
</article>
|
|
<article class="module-card">
|
|
<div class="module-card-head"><span class="eyebrow">BOARD</span></div>
|
|
<form id="settings-form">
|
|
<div class="field"><label>Default-Assignee</label><select class="field-input field-select" name="defaultAssignee">${assignees.map((a) => `<option value="${a.value}" ${a.value === normalizeAssignee(state.settings?.defaultAssignee) ? "selected" : ""}>${a.label}</option>`).join("")}</select></div>
|
|
<div class="field"><label>Zeitzone</label><input class="field-input" name="timezone" value="${escapeAttr(state.settings?.timezone || "Europe/Berlin")}"></div>
|
|
<div class="modal-actions"><button class="btn-submit" type="submit">Speichern</button></div>
|
|
</form>
|
|
</article>
|
|
</section>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function bindSettings() {
|
|
document.querySelector("#refresh-settings").addEventListener("click", async () => {
|
|
await loadSettings();
|
|
renderApp();
|
|
showToast("Settings aktualisiert");
|
|
});
|
|
document.querySelector("#settings-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const data = Object.fromEntries(new FormData(event.currentTarget));
|
|
await api("/api/settings", { method: "PATCH", body: JSON.stringify(data) });
|
|
await loadSettings();
|
|
renderApp();
|
|
showToast("Settings gespeichert");
|
|
});
|
|
}
|
|
|
|
function closeModal() {
|
|
state.dialogMode = null;
|
|
state.selectedTaskId = null;
|
|
renderApp();
|
|
}
|
|
|
|
function selectedTask() {
|
|
return state.tasks.find((task) => task.id === state.selectedTaskId);
|
|
}
|
|
|
|
function priorityLabel(priority) {
|
|
return priority === "High" ? "High" : priority === "Low" ? "Low" : "Med";
|
|
}
|
|
|
|
function priorityColor(priority) {
|
|
return priority === "High" ? "#f87171" : priority === "Low" ? "#60a5fa" : "#facc15";
|
|
}
|
|
|
|
function assigneeLabel(assignee) {
|
|
const match = assignees.find((item) => item.value === normalizeAssignee(assignee));
|
|
return match?.label || "Luna";
|
|
}
|
|
|
|
function assigneeClass(assignee) {
|
|
return normalizeAssignee(assignee) === "maxi" ? "assignee-maxi" : "assignee-luna";
|
|
}
|
|
|
|
function normalizeAssignee(assignee) {
|
|
const lower = String(assignee || "").toLowerCase();
|
|
return lower === "maxi" ? "maxi" : "luna";
|
|
}
|
|
|
|
function toastView() {
|
|
if (!state.toast) return "";
|
|
return `<div class="toast-stack" aria-live="polite"><div class="toast ${state.toast.type === "error" ? "toast-error" : "toast-success"}">${escapeHtml(state.toast.message)}</div></div>`;
|
|
}
|
|
|
|
function showToast(message, type = "success") {
|
|
state.toast = { message, type, id: Date.now() };
|
|
const toastId = state.toast.id;
|
|
renderApp();
|
|
window.clearTimeout(showToast.timer);
|
|
showToast.timer = window.setTimeout(() => {
|
|
if (state.toast?.id === toastId) {
|
|
state.toast = null;
|
|
renderApp();
|
|
}
|
|
}, 2600);
|
|
}
|
|
|
|
function stateLabel(value) {
|
|
const found = columns.find((column) => column.state === value);
|
|
return found?.label || value;
|
|
}
|
|
|
|
function statusTone(value) {
|
|
if (value === "Done") return "is-done";
|
|
if (value === "Blocked") return "is-blocked";
|
|
if (value === "Review") return "is-review";
|
|
if (value === "In progress") return "is-progress";
|
|
return "is-backlog";
|
|
}
|
|
|
|
function relativeTime(date) {
|
|
if (!date) return "keine Updates";
|
|
const mins = Math.max(0, Math.round((Date.now() - new Date(date).getTime()) / 60000));
|
|
if (mins < 1) return "gerade eben";
|
|
if (mins < 60) return `vor ${mins} min`;
|
|
const hours = Math.round(mins / 60);
|
|
if (hours < 24) return `vor ${hours} h`;
|
|
return `vor ${Math.round(hours / 24)} d`;
|
|
}
|
|
|
|
function formatDate(date, withTime = false) {
|
|
if (!date) return "-";
|
|
return new Date(date).toLocaleString("de-DE", withTime ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" });
|
|
}
|
|
|
|
function escapeHtml(value) {
|
|
return String(value ?? "")
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|
|
|
|
function escapeAttr(value) {
|
|
return escapeHtml(value).replaceAll("`", "`");
|
|
}
|
|
|
|
init();
|