791 lines
16 KiB
HTML
791 lines
16 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Chem Inventory</title>
|
|
|
|
<style>
|
|
:root {
|
|
--bg: #0f1115;
|
|
--panel: #171923;
|
|
--text: #e6e6e6;
|
|
--muted: #9aa0a6;
|
|
--accent: #7aa2f7;
|
|
--danger: #ef5350;
|
|
--border: #232634;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
font-family:
|
|
Inter,
|
|
ui-sans-serif,
|
|
system-ui,
|
|
sans-serif;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
}
|
|
|
|
header {
|
|
padding: 16px;
|
|
background: #11131a;
|
|
border-bottom: 1px solid var(--border);
|
|
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
h1 {
|
|
margin: 0;
|
|
font-size: 20px;
|
|
}
|
|
|
|
.spacer {
|
|
flex: 1;
|
|
}
|
|
|
|
main {
|
|
padding: 16px;
|
|
display: grid;
|
|
grid-template-columns: 360px 1fr;
|
|
gap: 16px;
|
|
}
|
|
|
|
@media (max-width: 900px) {
|
|
main {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
}
|
|
|
|
.card {
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
border-radius: 14px;
|
|
padding: 14px;
|
|
}
|
|
|
|
.card h2 {
|
|
margin-top: 0;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.row {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 8px;
|
|
}
|
|
|
|
.row3 {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr 1fr;
|
|
gap: 8px;
|
|
}
|
|
|
|
label {
|
|
display: block;
|
|
font-size: 12px;
|
|
margin-bottom: 4px;
|
|
color: var(--muted);
|
|
}
|
|
|
|
input,
|
|
select,
|
|
textarea,
|
|
button {
|
|
width: 100%;
|
|
border-radius: 10px;
|
|
border: 1px solid #2a2f3a;
|
|
background: #11131a;
|
|
color: var(--text);
|
|
padding: 10px;
|
|
}
|
|
|
|
textarea {
|
|
min-height: 80px;
|
|
resize: vertical;
|
|
}
|
|
|
|
button {
|
|
cursor: pointer;
|
|
}
|
|
|
|
button.primary {
|
|
background: var(--accent);
|
|
color: #0c1022;
|
|
font-weight: 700;
|
|
}
|
|
|
|
button.danger {
|
|
background: var(--danger);
|
|
color: white;
|
|
}
|
|
|
|
.toolbar {
|
|
display: flex;
|
|
gap: 8px;
|
|
flex-wrap: wrap;
|
|
}
|
|
|
|
.toolbar > * {
|
|
width: auto;
|
|
}
|
|
|
|
.muted {
|
|
color: var(--muted);
|
|
}
|
|
|
|
.status {
|
|
font-size: 12px;
|
|
}
|
|
|
|
table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
|
|
th,
|
|
td {
|
|
padding: 10px;
|
|
border-bottom: 1px solid var(--border);
|
|
text-align: left;
|
|
font-size: 13px;
|
|
vertical-align: top;
|
|
}
|
|
|
|
th {
|
|
position: sticky;
|
|
top: 0;
|
|
background: var(--panel);
|
|
}
|
|
|
|
tr:hover td {
|
|
background: #121522;
|
|
}
|
|
|
|
.pill {
|
|
padding: 2px 8px;
|
|
border-radius: 999px;
|
|
background: #1e2435;
|
|
border: 1px solid #2a2f3a;
|
|
display: inline-block;
|
|
}
|
|
|
|
.right {
|
|
text-align: right;
|
|
}
|
|
|
|
.kbd {
|
|
font-family: monospace;
|
|
background: #202636;
|
|
padding: 2px 6px;
|
|
border-radius: 5px;
|
|
}
|
|
</style>
|
|
|
|
<script
|
|
src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/sql-wasm.js"
|
|
integrity="sha512-Rw3r47C4BVCj/4dy0MiF30B9c7+dLNeJdgPkyw8KiiYqUzAP3XFFw90EjO7mHLkJBl7JCm+/iTuL9dGh47lbMw=="
|
|
crossorigin="anonymous"
|
|
referrerpolicy="no-referrer">
|
|
</script>
|
|
</head>
|
|
|
|
<body>
|
|
<header>
|
|
<h1>Chem Inventory</h1>
|
|
|
|
<span class="muted">Offline SQLite inventory</span>
|
|
|
|
<div class="spacer"></div>
|
|
|
|
<div class="toolbar">
|
|
<button id="exportDbBtn">Export DB</button>
|
|
<button id="exportCsvBtn">Export CSV</button>
|
|
|
|
<label class="kbd" for="importDb">
|
|
Import DB
|
|
</label>
|
|
|
|
<input
|
|
id="importDb"
|
|
type="file"
|
|
accept=".db,.sqlite"
|
|
hidden />
|
|
</div>
|
|
</header>
|
|
|
|
<main>
|
|
<section class="card">
|
|
<h2>New Entry</h2>
|
|
|
|
<div class="row">
|
|
<div>
|
|
<label>CAS</label>
|
|
<input id="cas" placeholder="67-56-1" />
|
|
</div>
|
|
|
|
<div>
|
|
<label>Name</label>
|
|
<input id="name" placeholder="Methanol" />
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row3" style="margin-top:8px">
|
|
<div>
|
|
<label>Quantity</label>
|
|
<input id="qty" type="number" step="0.01" />
|
|
</div>
|
|
|
|
<div>
|
|
<label>Unit</label>
|
|
<select id="unit">
|
|
<option>mg</option>
|
|
<option>g</option>
|
|
<option>kg</option>
|
|
<option selected>mL</option>
|
|
<option>L</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div>
|
|
<label>Container</label>
|
|
<select id="container">
|
|
<option selected>glass</option>
|
|
<option>plastic</option>
|
|
<option>metal</option>
|
|
<option>other</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div style="margin-top:8px">
|
|
<label>Notes</label>
|
|
<textarea id="notes"></textarea>
|
|
</div>
|
|
|
|
<div class="toolbar" style="margin-top:10px">
|
|
<button id="addBtn" class="primary">
|
|
Add
|
|
</button>
|
|
|
|
<button id="lookupBtn">
|
|
PubChem Lookup
|
|
</button>
|
|
|
|
<label>
|
|
<input id="autoLookup" type="checkbox" />
|
|
Auto lookup
|
|
</label>
|
|
</div>
|
|
|
|
<p class="muted">
|
|
<span class="kbd">Enter</span> add entry •
|
|
<span class="kbd">/</span> CAS focus •
|
|
<span class="kbd">,</span> Name focus
|
|
</p>
|
|
|
|
<div id="status" class="status muted"></div>
|
|
</section>
|
|
|
|
<section class="card">
|
|
<h2>Inventory</h2>
|
|
|
|
<div class="toolbar" style="margin-bottom:8px">
|
|
<input
|
|
id="search"
|
|
placeholder="Search..."
|
|
style="flex:1" />
|
|
|
|
<button id="clearBtn">
|
|
Clear
|
|
</button>
|
|
</div>
|
|
|
|
<div style="overflow:auto;max-height:75vh">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>CAS</th>
|
|
<th>Name</th>
|
|
<th>Qty</th>
|
|
<th>Unit</th>
|
|
<th>Container</th>
|
|
<th>Notes</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
|
|
<tbody id="tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</section>
|
|
</main>
|
|
|
|
<script>
|
|
const DB_NAME = "chem_inventory";
|
|
const STORE_NAME = "sqlite";
|
|
const DB_KEY = "main";
|
|
|
|
let SQL;
|
|
let db;
|
|
let readyPromise;
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
function setStatus(msg, cls = "muted") {
|
|
const el = $("status");
|
|
|
|
el.className = `status ${cls}`;
|
|
el.textContent = msg;
|
|
|
|
if (msg) {
|
|
setTimeout(() => {
|
|
if (el.textContent === msg) {
|
|
el.textContent = "";
|
|
}
|
|
}, 4000);
|
|
}
|
|
}
|
|
|
|
async function openIdb() {
|
|
return new Promise((resolve, reject) => {
|
|
const req = indexedDB.open(DB_NAME, 1);
|
|
|
|
req.onupgradeneeded = () => {
|
|
req.result.createObjectStore(STORE_NAME);
|
|
};
|
|
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function saveDb() {
|
|
const idb = await openIdb();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const tx = idb.transaction(STORE_NAME, "readwrite");
|
|
|
|
tx.objectStore(STORE_NAME).put(db.export(), DB_KEY);
|
|
|
|
tx.oncomplete = () => resolve();
|
|
tx.onerror = () => reject(tx.error);
|
|
});
|
|
}
|
|
|
|
async function loadDbBytes() {
|
|
const idb = await openIdb();
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const tx = idb.transaction(STORE_NAME, "readonly");
|
|
|
|
const req = tx.objectStore(STORE_NAME).get(DB_KEY);
|
|
|
|
req.onsuccess = () => resolve(req.result);
|
|
req.onerror = () => reject(req.error);
|
|
});
|
|
}
|
|
|
|
async function init() {
|
|
SQL = await initSqlJs({
|
|
locateFile: file =>
|
|
`https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/${file}`
|
|
});
|
|
|
|
const saved = await loadDbBytes();
|
|
|
|
if (saved) {
|
|
db = new SQL.Database(saved);
|
|
} else {
|
|
db = new SQL.Database();
|
|
|
|
db.run(`
|
|
CREATE TABLE items (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
cas TEXT,
|
|
name TEXT,
|
|
qty REAL,
|
|
unit TEXT,
|
|
container TEXT,
|
|
notes TEXT,
|
|
created_at TEXT
|
|
);
|
|
|
|
CREATE INDEX idx_cas ON items(cas);
|
|
CREATE INDEX idx_name ON items(name);
|
|
`);
|
|
|
|
await saveDb();
|
|
}
|
|
|
|
render();
|
|
|
|
setStatus("Database ready", "ok");
|
|
}
|
|
|
|
async function addItem(rec) {
|
|
const stmt = db.prepare(`
|
|
INSERT INTO items (
|
|
cas,
|
|
name,
|
|
qty,
|
|
unit,
|
|
container,
|
|
notes,
|
|
created_at
|
|
)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
`);
|
|
|
|
stmt.run([
|
|
rec.cas || null,
|
|
rec.name || null,
|
|
rec.qty ? Number(rec.qty) : null,
|
|
rec.unit || null,
|
|
rec.container || null,
|
|
rec.notes || null,
|
|
new Date().toISOString()
|
|
]);
|
|
|
|
stmt.free();
|
|
|
|
await saveDb();
|
|
|
|
render();
|
|
}
|
|
|
|
async function deleteItem(id) {
|
|
db.run("DELETE FROM items WHERE id = ?", [id]);
|
|
|
|
await saveDb();
|
|
|
|
render();
|
|
}
|
|
|
|
function query(filter = "") {
|
|
let sql = "SELECT * FROM items";
|
|
const params = [];
|
|
|
|
if (filter) {
|
|
sql += `
|
|
WHERE
|
|
cas LIKE ?
|
|
OR name LIKE ?
|
|
OR notes LIKE ?
|
|
`;
|
|
|
|
const f = `%${filter}%`;
|
|
|
|
params.push(f, f, f);
|
|
}
|
|
|
|
sql += " ORDER BY id DESC";
|
|
|
|
const res = db.exec(sql, params);
|
|
|
|
if (!res.length) {
|
|
return [];
|
|
}
|
|
|
|
const cols = res[0].columns;
|
|
|
|
return res[0].values.map(row => {
|
|
const obj = {};
|
|
|
|
cols.forEach((c, i) => {
|
|
obj[c] = row[i];
|
|
});
|
|
|
|
return obj;
|
|
});
|
|
}
|
|
|
|
function render() {
|
|
const rows = query($("search").value.trim());
|
|
|
|
const tbody = $("tbody");
|
|
|
|
tbody.innerHTML = "";
|
|
|
|
for (const row of rows) {
|
|
const tr = document.createElement("tr");
|
|
|
|
tr.innerHTML = `
|
|
<td>${row.id}</td>
|
|
<td><span class="pill">${escapeHtml(row.cas || "")}</span></td>
|
|
<td>${escapeHtml(row.name || "")}</td>
|
|
<td class="right">${row.qty ?? ""}</td>
|
|
<td>${escapeHtml(row.unit || "")}</td>
|
|
<td>${escapeHtml(row.container || "")}</td>
|
|
<td style="white-space:pre-wrap">
|
|
${escapeHtml(row.notes || "")}
|
|
</td>
|
|
<td>
|
|
<button
|
|
class="danger"
|
|
data-delete="${row.id}">
|
|
Delete
|
|
</button>
|
|
</td>
|
|
`;
|
|
|
|
tbody.appendChild(tr);
|
|
}
|
|
}
|
|
|
|
function resetForm() {
|
|
$("cas").value = "";
|
|
$("name").value = "";
|
|
$("qty").value = "";
|
|
$("unit").value = "mL";
|
|
$("container").value = "glass";
|
|
$("notes").value = "";
|
|
|
|
$("cas").focus();
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
return str
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
async function pubchemLookup(cas, name) {
|
|
const out = {
|
|
cas: cas.trim(),
|
|
name: name.trim()
|
|
};
|
|
|
|
try {
|
|
if (out.cas && !out.name) {
|
|
const r = await fetch(
|
|
`https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/${encodeURIComponent(out.cas)}/property/IUPACName/JSON`
|
|
);
|
|
|
|
if (r.ok) {
|
|
const j = await r.json();
|
|
|
|
out.name =
|
|
j?.PropertyTable?.Properties?.[0]?.IUPACName || "";
|
|
}
|
|
}
|
|
|
|
if (!out.cas && out.name) {
|
|
const r = await fetch(
|
|
`https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/${encodeURIComponent(out.name)}/xrefs/RN/JSON`
|
|
);
|
|
|
|
if (r.ok) {
|
|
const j = await r.json();
|
|
|
|
out.cas =
|
|
j?.InformationList?.Information?.[0]?.RN?.[0] || "";
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function download(blob, filename) {
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const a = document.createElement("a");
|
|
|
|
a.href = url;
|
|
a.download = filename;
|
|
|
|
a.click();
|
|
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
async function exportDb() {
|
|
const blob = new Blob(
|
|
[db.export()],
|
|
{ type: "application/octet-stream" }
|
|
);
|
|
|
|
download(blob, "chem_inventory.sqlite");
|
|
}
|
|
|
|
async function exportCsv() {
|
|
const rows = query();
|
|
|
|
const header = [
|
|
"id",
|
|
"cas",
|
|
"name",
|
|
"qty",
|
|
"unit",
|
|
"container",
|
|
"notes",
|
|
"created_at"
|
|
];
|
|
|
|
const csv = [
|
|
header.join(","),
|
|
...rows.map(row =>
|
|
header
|
|
.map(h => `"${String(row[h] ?? "").replaceAll('"', '""')}"`)
|
|
.join(",")
|
|
)
|
|
].join("\n");
|
|
|
|
const blob = new Blob(
|
|
[csv],
|
|
{ type: "text/csv" }
|
|
);
|
|
|
|
download(blob, "chem_inventory.csv");
|
|
}
|
|
|
|
async function importDb(file) {
|
|
const buf = await file.arrayBuffer();
|
|
|
|
db = new SQL.Database(new Uint8Array(buf));
|
|
|
|
await saveDb();
|
|
|
|
render();
|
|
|
|
setStatus("Database imported", "ok");
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
readyPromise = init();
|
|
|
|
try {
|
|
await readyPromise;
|
|
} catch (err) {
|
|
console.error(err);
|
|
|
|
setStatus("Initialization failed", "bad");
|
|
}
|
|
});
|
|
|
|
$("addBtn").addEventListener("click", async () => {
|
|
await readyPromise;
|
|
|
|
const rec = {
|
|
cas: $("cas").value.trim(),
|
|
name: $("name").value.trim(),
|
|
qty: $("qty").value,
|
|
unit: $("unit").value,
|
|
container: $("container").value,
|
|
notes: $("notes").value.trim()
|
|
};
|
|
|
|
if (!rec.cas && !rec.name) {
|
|
setStatus("Need CAS or name", "bad");
|
|
return;
|
|
}
|
|
|
|
await addItem(rec);
|
|
|
|
resetForm();
|
|
|
|
setStatus("Entry added", "ok");
|
|
});
|
|
|
|
$("lookupBtn").addEventListener("click", async () => {
|
|
const cas = $("cas").value;
|
|
const name = $("name").value;
|
|
|
|
if (!cas && !name) {
|
|
setStatus("Nothing to lookup", "bad");
|
|
return;
|
|
}
|
|
|
|
setStatus("Querying PubChem...");
|
|
|
|
const res = await pubchemLookup(cas, name);
|
|
|
|
if (res.cas && !$("cas").value) {
|
|
$("cas").value = res.cas;
|
|
}
|
|
|
|
if (res.name && !$("name").value) {
|
|
$("name").value = res.name;
|
|
}
|
|
|
|
setStatus("Lookup complete", "ok");
|
|
});
|
|
|
|
$("search").addEventListener("input", render);
|
|
|
|
$("clearBtn").addEventListener("click", () => {
|
|
$("search").value = "";
|
|
render();
|
|
});
|
|
|
|
$("tbody").addEventListener("click", async (e) => {
|
|
const btn = e.target.closest("[data-delete]");
|
|
|
|
if (!btn) {
|
|
return;
|
|
}
|
|
|
|
const id = Number(btn.dataset.delete);
|
|
|
|
if (!confirm(`Delete item ${id}?`)) {
|
|
return;
|
|
}
|
|
|
|
await deleteItem(id);
|
|
|
|
setStatus("Deleted", "ok");
|
|
});
|
|
|
|
$("exportDbBtn").addEventListener("click", exportDb);
|
|
|
|
$("exportCsvBtn").addEventListener("click", exportCsv);
|
|
|
|
$("importDb").addEventListener("change", async (e) => {
|
|
const file = e.target.files?.[0];
|
|
|
|
if (!file) {
|
|
return;
|
|
}
|
|
|
|
await importDb(file);
|
|
|
|
e.target.value = "";
|
|
});
|
|
|
|
document.addEventListener("keydown", (e) => {
|
|
if (
|
|
e.key === "Enter" &&
|
|
document.activeElement.tagName !== "TEXTAREA"
|
|
) {
|
|
e.preventDefault();
|
|
|
|
$("addBtn").click();
|
|
}
|
|
|
|
if (e.key === "/") {
|
|
e.preventDefault();
|
|
$("cas").focus();
|
|
}
|
|
|
|
if (e.key === ",") {
|
|
e.preventDefault();
|
|
$("name").focus();
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|