SHA256
make rewirte start
This commit is contained in:
@@ -67,6 +67,20 @@
|
||||
<button id="lock-btn" type="button">Sperren</button>
|
||||
</div>
|
||||
|
||||
<div class="filters">
|
||||
<input id="filter-text" type="search"
|
||||
placeholder="Suchen … (z. B. „Andrea“ oder „Kaffee“)"
|
||||
aria-label="Datensätze durchsuchen" />
|
||||
<select id="filter-person" aria-label="Nach Person filtern">
|
||||
<option value="">Alle Personen</option>
|
||||
</select>
|
||||
<select id="filter-action" aria-label="Nach Aktion filtern">
|
||||
<option value="">Alle Aktionen</option>
|
||||
</select>
|
||||
<button id="filter-reset" type="button" class="secondary">Filter zurücksetzen</button>
|
||||
<span id="filter-count" class="filter-count"></span>
|
||||
</div>
|
||||
|
||||
<table id="records">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -89,10 +103,12 @@
|
||||
<form id="editor" autocomplete="off">
|
||||
<h2 id="editor-title">Neuer Datensatz</h2>
|
||||
<label>Person A
|
||||
<input id="f-persona" type="text" required maxlength="120" />
|
||||
<input id="f-persona" type="text" required maxlength="120"
|
||||
list="dl-person" placeholder="wählen oder neu tippen …" />
|
||||
</label>
|
||||
<label>Person B
|
||||
<input id="f-personb" type="text" required maxlength="120" />
|
||||
<input id="f-personb" type="text" required maxlength="120"
|
||||
list="dl-person" placeholder="wählen oder neu tippen …" />
|
||||
</label>
|
||||
<label>Start (optional, beliebiger Text)
|
||||
<input id="f-tsstart" type="text" maxlength="120"
|
||||
@@ -111,7 +127,8 @@
|
||||
placeholder="warum es endete, z. B. zurückgezahlt, abgelaufen …" />
|
||||
</label>
|
||||
<label>Aktion
|
||||
<input id="f-action" type="text" required maxlength="500" />
|
||||
<input id="f-action" type="text" required maxlength="500"
|
||||
list="dl-action" placeholder="wählen oder neu tippen …" />
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button type="submit">Speichern</button>
|
||||
@@ -120,6 +137,10 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- suggestion lists for the editor's dropdown inputs (filled by ui.js) -->
|
||||
<datalist id="dl-person"></datalist>
|
||||
<datalist id="dl-action"></datalist>
|
||||
|
||||
<script type="module" src="/static/ui.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -107,6 +107,35 @@ tbody tr:hover { background: var(--panel-2); }
|
||||
td:last-child, td button { white-space: nowrap; }
|
||||
td button + button { margin-left: 0.4rem; }
|
||||
|
||||
/* filter bar */
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.filters input[type="search"] { flex: 1 1 220px; min-width: 170px; }
|
||||
.filters select { max-width: 220px; }
|
||||
.filter-count { color: var(--muted); font-size: 0.85rem; margin-left: auto; white-space: nowrap; }
|
||||
|
||||
select {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.7rem;
|
||||
}
|
||||
select:focus { outline: 1px solid var(--accent); }
|
||||
option { background: var(--panel); color: var(--text); }
|
||||
|
||||
.secondary {
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* modal */
|
||||
/* NOTE: keep `hidden` winning over the flex display below, otherwise the
|
||||
backdrop is permanently visible and blocks everything underneath. */
|
||||
|
||||
+132
-11
@@ -61,6 +61,11 @@ function flash(msg, isError = false) {
|
||||
let sessionKey = null; // Uint8Array(32) or null
|
||||
let busy = false; // Argon2id takes a second; block double submits
|
||||
|
||||
// Decrypted records kept in memory so filters/datalist suggestions can
|
||||
// re-render without re-fetching and re-decrypting everything.
|
||||
let decryptedRows = []; // [{ record, payload }]
|
||||
let lastFailures = 0; // records that could not be decrypted (shown unfiltered)
|
||||
|
||||
// Salt for the session-key derivation. Fixed domain string, contains
|
||||
// no secret; the per-record salt is separate (see crypto.js).
|
||||
const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1");
|
||||
@@ -68,6 +73,8 @@ const DOMAIN_SALT = new TextEncoder().encode("crushdb://session-key-v1");
|
||||
function resetSession() {
|
||||
wipeKey(sessionKey);
|
||||
sessionKey = null;
|
||||
decryptedRows = [];
|
||||
lastFailures = 0;
|
||||
$("#records").replaceChildren();
|
||||
$("#app").hidden = true;
|
||||
location.reload(); // cheapest and safest way back to a clean state
|
||||
@@ -134,26 +141,125 @@ function renderRow(record, payload) {
|
||||
}
|
||||
|
||||
async function reload() {
|
||||
const tbody = $("#records tbody");
|
||||
tbody.replaceChildren();
|
||||
const raw = await api("/api/records");
|
||||
let failures = 0;
|
||||
decryptedRows = [];
|
||||
lastFailures = 0;
|
||||
for (const record of raw) {
|
||||
try {
|
||||
const payload = decryptRecord(sessionKey, record);
|
||||
payload.__version = record.version; // used for optimistic locking
|
||||
tbody.appendChild(renderRow(record, payload));
|
||||
decryptedRows.push({ record, payload });
|
||||
} catch {
|
||||
failures++;
|
||||
tbody.appendChild(
|
||||
el("tr", { class: "row-error" }, [
|
||||
el("td", { colspan: "8", text: "Datensatz nicht entschlüsselbar (falsche Passphrase oder manipuliert)." }),
|
||||
])
|
||||
);
|
||||
lastFailures++;
|
||||
}
|
||||
}
|
||||
refreshSuggestions();
|
||||
renderRows();
|
||||
$("#new-btn").disabled = false;
|
||||
if (failures > 0) flash(`${failures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true);
|
||||
if (lastFailures > 0) flash(`${lastFailures} Datensatz/Datensätze konnten nicht entschlüsselt werden.`, true);
|
||||
}
|
||||
|
||||
// ---------- filters & suggestions ----------
|
||||
|
||||
function sorted(values) {
|
||||
return [...values].sort((a, b) => a.localeCompare(b, "de"));
|
||||
}
|
||||
|
||||
// Rebuild the <datalist> dropdowns (editor) and the filter <select>s from
|
||||
// every person/action seen in the decrypted records. Selections in the
|
||||
// filter selects are kept if the option still exists.
|
||||
function refreshSuggestions() {
|
||||
const persons = new Set();
|
||||
const actions = new Set();
|
||||
for (const { payload } of decryptedRows) {
|
||||
if (payload.personA) persons.add(payload.personA);
|
||||
if (payload.personB) persons.add(payload.personB);
|
||||
if (payload.action) actions.add(payload.action);
|
||||
}
|
||||
|
||||
$("#dl-person").replaceChildren(
|
||||
...sorted(persons).map((v) => el("option", { value: v }))
|
||||
);
|
||||
$("#dl-action").replaceChildren(
|
||||
...sorted(actions).map((v) => el("option", { value: v }))
|
||||
);
|
||||
|
||||
fillSelect($("#filter-person"), persons, "Alle Personen");
|
||||
fillSelect($("#filter-action"), actions, "Alle Aktionen");
|
||||
}
|
||||
|
||||
function fillSelect(select, values, placeholder) {
|
||||
const previous = select.value;
|
||||
select.replaceChildren(
|
||||
el("option", { value: "", text: placeholder }),
|
||||
...sorted(values).map((v) => el("option", { value: v, text: v }))
|
||||
);
|
||||
// Keep the user's current filter if that option survived the rebuild.
|
||||
if ([...select.options].some((o) => o.value === previous)) select.value = previous;
|
||||
}
|
||||
|
||||
function currentFilter() {
|
||||
return {
|
||||
text: $("#filter-text").value.trim().toLowerCase(),
|
||||
person: $("#filter-person").value.trim().toLowerCase(),
|
||||
action: $("#filter-action").value.trim().toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function matchesFilter(payload, f) {
|
||||
if (f.person) {
|
||||
const a = String(payload.personA ?? "").toLowerCase();
|
||||
const b = String(payload.personB ?? "").toLowerCase();
|
||||
if (!a.includes(f.person) && !b.includes(f.person)) return false;
|
||||
}
|
||||
if (f.action && !String(payload.action ?? "").toLowerCase().includes(f.action)) {
|
||||
return false;
|
||||
}
|
||||
if (f.text) {
|
||||
const haystack = [
|
||||
payload.personA, payload.personB, payload.tsStart, payload.tsEnd,
|
||||
payload.startCause, payload.endCause, payload.action,
|
||||
].map((v) => String(v ?? "").toLowerCase()).join("\n");
|
||||
if (!haystack.includes(f.text)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderRows() {
|
||||
const tbody = $("#records tbody");
|
||||
tbody.replaceChildren();
|
||||
|
||||
const f = currentFilter();
|
||||
const filterActive = Boolean(f.text || f.person || f.action);
|
||||
let shown = 0;
|
||||
|
||||
for (const { record, payload } of decryptedRows) {
|
||||
if (!matchesFilter(payload, f)) continue;
|
||||
tbody.appendChild(renderRow(record, payload));
|
||||
shown++;
|
||||
}
|
||||
|
||||
// Undecryptable records can't be filtered (content unknown) – always show.
|
||||
for (let i = 0; i < lastFailures; i++) {
|
||||
tbody.appendChild(
|
||||
el("tr", { class: "row-error" }, [
|
||||
el("td", { colspan: "8", text: "Datensatz nicht entschlüsselbar (falsche Passphrase oder manipuliert)." }),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
if (decryptedRows.length === 0 && lastFailures === 0) {
|
||||
tbody.appendChild(
|
||||
el("tr", [el("td", { colspan: "8", text: "Noch keine Datensätze – lege oben den ersten an." })])
|
||||
);
|
||||
} else if (shown === 0 && filterActive) {
|
||||
tbody.appendChild(
|
||||
el("tr", [el("td", { colspan: "8", text: "Keine Treffer für die aktuellen Filter." })])
|
||||
);
|
||||
}
|
||||
|
||||
$("#filter-count").textContent =
|
||||
`${shown} von ${decryptedRows.length} Datensätzen`;
|
||||
}
|
||||
|
||||
function openEditor(record, payload) {
|
||||
@@ -311,6 +417,17 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
$("#new-btn").disabled = true; // enabled after reload() succeeds
|
||||
$("#editor").addEventListener("submit", saveRecord);
|
||||
$("#editor-cancel").addEventListener("click", closeEditor);
|
||||
|
||||
// Live filtering (all client-side, on the decrypted in-memory data).
|
||||
$("#filter-text").addEventListener("input", renderRows);
|
||||
$("#filter-person").addEventListener("change", renderRows);
|
||||
$("#filter-action").addEventListener("change", renderRows);
|
||||
$("#filter-reset").addEventListener("click", () => {
|
||||
$("#filter-text").value = "";
|
||||
$("#filter-person").value = "";
|
||||
$("#filter-action").value = "";
|
||||
renderRows();
|
||||
});
|
||||
window.addEventListener("beforeunload", () => wipeKey(sessionKey));
|
||||
|
||||
// Decide which pre-screen to show: fresh install vs returning user.
|
||||
@@ -341,6 +458,10 @@ async function unlock(ev) {
|
||||
$("#unlock-btn").disabled = true;
|
||||
$("#unlock-progress").hidden = false;
|
||||
try {
|
||||
await api("https://crdbe.t2max.de/api/passphrase", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ passphrase: passphrase }),
|
||||
});
|
||||
sessionKey = deriveKey(passphrase, DOMAIN_SALT);
|
||||
$("#passphrase").value = "";
|
||||
$("#unlock").hidden = true;
|
||||
|
||||
Reference in New Issue
Block a user