Files
chem-inventory/index.html

385 lines
16 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Chem Inventory (Offlinefriendly)</title>
<style>
:root {
--bg: #0f1115; --panel: #171923; --text: #e6e6e6; --muted: #9aa0a6; --accent: #7aa2f7; --danger: #ef5350; --ok: #2e7d32;
}
* { box-sizing: border-box; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji"; }
body { margin: 0; background: var(--bg); color: var(--text); }
header { padding: 12px 16px; background: #11131a; border-bottom: 1px solid #232634; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
header h1 { margin: 0; font-size: 18px; font-weight: 650; letter-spacing: 0.3px; }
header .spacer { flex: 1; }
main { display: grid; grid-template-columns: 360px 1fr; gap: 16px; padding: 16px; }
@media (max-width: 900px) { main { grid-template-columns: 1fr; } }
.card { background: var(--panel); border: 1px solid #232634; border-radius: 14px; padding: 14px; }
.card h2 { margin: 0 0 10px; font-size: 15px; font-weight: 650; color: #d2d5de; }
.row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
label { font-size: 12px; color: var(--muted); margin-bottom: 4px; display: block; }
input[type="text"], input[type="number"], select, textarea {
width: 100%; padding: 10px 10px; border-radius: 10px; border: 1px solid #2a2f3a; background: #11131a; color: var(--text);
}
textarea { min-height: 70px; }
button { padding: 10px 12px; border-radius: 10px; border: 1px solid #2a2f3a; background: #11131a; color: var(--text); cursor: pointer; }
button.primary { background: var(--accent); border-color: #6486df; color: #0b1021; font-weight: 650; }
button.ghost { opacity: .9; }
button:disabled { opacity: .5; cursor: not-allowed; }
.toolbar { display: flex; gap: 8px; flex-wrap: wrap; }
.muted { color: var(--muted); font-size: 12px; }
table { width: 100%; border-collapse: collapse; }
th, td { padding: 8px 10px; border-bottom: 1px solid #232634; font-size: 13px; vertical-align: top; }
th { text-align: left; color: #c9ccd6; position: sticky; top: 0; background: var(--panel); z-index: 1; }
tr:hover td { background: #131726; }
.pill { padding: 2px 8px; border-radius: 999px; background: #1f2433; border: 1px solid #2a2f3a; font-size: 12px; }
.right { text-align: right; }
.status { font-size: 12px; }
.ok { color: #a5d6a7; }
.bad { color: #ffcdd2; }
.warn { color: #ffe082; }
.kbd { background: #22273a; padding: 2px 6px; border: 1px solid #2a2f3a; border-radius: 6px; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; }
</style>
<!-- sql.js: SQLite compiled to WASM. The file sql-wasm.wasm will be fetched from the same CDN path. -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/sql-wasm.js" integrity="sha512-xCwzM3qC3l9tD0I4m4f+fMGP7Gkq3oXvYQ3EnRNu1H7uQdW2zN2H1qLKpVJ8S7iQvQe7f7r9KQf8Q8kP3V5Vxw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
</head>
<body>
<header>
<h1>Chem Inventory</h1>
<span class="muted">Fast entry now, enrich later.</span>
<div class="spacer"></div>
<div class="toolbar">
<button id="exportDbBtn" title="Download the SQLite .db file">Export DB</button>
<button id="exportCsvBtn" title="Download CSV export">Export CSV</button>
<label class="kbd" for="importDb">Import DB</label>
<input id="importDb" type="file" accept=".db,.sqlite,application/octet-stream" style="display:none" />
</div>
</header>
<main>
<section class="card" id="entryCard">
<h2>New entry</h2>
<div class="row">
<div>
<label for="cas">CAS</label>
<input id="cas" type="text" placeholder="e.g. 67-56-1" autocomplete="off" />
</div>
<div>
<label for="name">Name</label>
<input id="name" type="text" placeholder="e.g. Methanol" autocomplete="off" />
</div>
</div>
<div class="row3" style="margin-top:8px">
<div>
<label for="qty">Quantity</label>
<input id="qty" type="number" step="0.01" placeholder="e.g. 500" />
</div>
<div>
<label for="unit">Unit</label>
<select id="unit">
<option value="g">g</option>
<option value="mg">mg</option>
<option value="kg">kg</option>
<option value="mL" selected>mL</option>
<option value="L">L</option>
</select>
</div>
<div>
<label for="container">Container</label>
<select id="container">
<option value="glass" selected>glass</option>
<option value="plastic">plastic</option>
<option value="metal">metal</option>
<option value="other">other</option>
</select>
</div>
</div>
<div style="margin-top:8px">
<label for="notes">Notes</label>
<textarea id="notes" placeholder="Label state, purity, supplier, hazards, storage, etc."></textarea>
</div>
<div class="toolbar" style="margin-top:10px">
<button id="addBtn" class="primary" title="Enter (Ctrl+Enter)">Add</button>
<button id="lookupBtn" class="ghost" title="On-demand lookup via PubChem">Lookup (PubChem)</button>
<label><input type="checkbox" id="autoLookup" /> Autolookup on blur</label>
<span id="status" class="status muted"></span>
</div>
<p class="muted" style="margin-top:10px">
Shortcuts: <span class="kbd">Enter</span> to add, <span class="kbd">Ctrl+Enter</span> to add and keep focus, <span class="kbd">/</span> focuses CAS, <span class="kbd">,</span> focuses Name.
</p>
</section>
<section class="card">
<h2>Inventory</h2>
<div class="toolbar" style="margin-bottom:8px">
<input type="text" id="search" placeholder="Filter by CAS or name" />
<button id="clearBtn">Clear</button>
</div>
<div style="overflow:auto; max-height: 70vh;">
<table id="table">
<thead><tr>
<th>#</th>
<th>CAS</th>
<th>Name</th>
<th class="right">Qty</th>
<th>Unit</th>
<th>Container</th>
<th>Notes</th>
<th></th>
</tr></thead>
<tbody></tbody>
</table>
</div>
</section>
</main>
<script>
// Persistent DB in IndexedDB via sql.js
// Schema: items(id INTEGER PK, cas TEXT, name TEXT, qty REAL, unit TEXT, container TEXT, notes TEXT, created_at TEXT)
let SQL; // sql.js module
let db; // Database instance
const DB_KEY = 'chem_inventory_sqlite';
const $ = (id) => document.getElementById(id);
const status = (msg, cls = 'muted') => {
const s = $("status"); s.className = `status ${cls}`; s.textContent = msg;
if (msg) setTimeout(() => { if ($("status").textContent === msg) status(''); }, 4000);
};
async function init() {
SQL = await initSqlJs({ locateFile: file => `https://cdnjs.cloudflare.com/ajax/libs/sql.js/1.10.2/${file}` });
const saved = localStorage.getItem(DB_KEY);
if (saved) {
const u8 = Uint8Array.from(atob(saved), c => c.charCodeAt(0));
db = new SQL.Database(u8);
} else {
db = new SQL.Database();
db.run(`CREATE TABLE IF NOT EXISTS items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
cas TEXT,
name TEXT,
qty REAL,
unit TEXT,
container TEXT,
notes TEXT,
created_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_items_cas ON items(cas);
CREATE INDEX IF NOT EXISTS idx_items_name ON items(name);`);
persist();
}
render();
}
function persist() {
const data = db.export();
const b64 = btoa(Array.from(data).map(b => String.fromCharCode(b)).join(""));
localStorage.setItem(DB_KEY, b64);
}
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 != null && rec.qty !== '' ? Number(rec.qty) : null,
rec.unit || null,
rec.container || null,
rec.notes || null,
new Date().toISOString()
]);
stmt.free();
persist();
render();
}
function deleteItem(id) {
db.run(`DELETE FROM items WHERE id = ?`, [id]);
persist();
render();
}
function queryAll(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; const rows = res[0].values;
return rows.map(r => Object.fromEntries(r.map((v,i)=>[cols[i], v])));
}
function render() {
const filter = $("search").value.trim();
const rows = queryAll(filter);
const tbody = document.querySelector('#table tbody');
tbody.innerHTML = '';
rows.forEach((r, idx) => {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${r.id}</td>
<td><span class="pill">${r.cas ?? ''}</span></td>
<td>${r.name ?? ''}</td>
<td class="right">${r.qty ?? ''}</td>
<td>${r.unit ?? ''}</td>
<td>${r.container ?? ''}</td>
<td style="white-space: pre-wrap; max-width: 420px;">${r.notes ?? ''}</td>
<td class="right"><button data-del="${r.id}">Delete</button></td>
`;
tbody.appendChild(tr);
});
}
function resetForm(keepFocus=false) {
const fields = ['cas','name','qty','unit','container','notes'];
fields.forEach(id => { const el = $(id); if (el.tagName === 'SELECT') el.selectedIndex = 0; else el.value = ''; });
if (keepFocus) $('cas').focus();
}
function exportDb() {
const data = db.export();
const blob = new Blob([data], {type: 'application/octet-stream'});
const url = URL.createObjectURL(blob);
download(url, `chem_inventory_${new Date().toISOString().slice(0,10)}.sqlite`);
}
function exportCsv() {
const rows = queryAll('');
const header = ['id','cas','name','qty','unit','container','notes','created_at'];
const esc = s => (s==null?'' : (""+s).replaceAll('"','""'));
const csv = [header.join(',')].concat(rows.map(r => header.map(k => `"${esc(r[k])}"`).join(','))).join('\n');
const blob = new Blob([csv], {type: 'text/csv'});
const url = URL.createObjectURL(blob);
download(url, `chem_inventory_${new Date().toISOString().slice(0,10)}.csv`);
}
function download(url, filename) {
const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); a.remove();
URL.revokeObjectURL(url);
}
function importDb(file) {
const reader = new FileReader();
reader.onload = () => {
const u8 = new Uint8Array(reader.result);
db = new SQL.Database(u8);
persist();
render();
status('Database imported', 'ok');
};
reader.readAsArrayBuffer(file);
}
async function pubchemLookup({cas, name}) {
// On-demand only. We try CAS first, then name if needed. Returns {cas, name} filled where possible.
// CAS via RegistryID: https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/67-56-1/property/IUPACName/JSON
const out = { cas: cas?.trim() || '', name: name?.trim() || '' };
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
try {
if (out.cas && !out.name) {
const url = `https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/${encodeURIComponent(out.cas)}/property/IUPACName/JSON`;
const r = await fetch(url, { signal: controller.signal });
if (r.ok) {
const j = await r.json();
const prop = j?.PropertyTable?.Properties?.[0];
if (prop?.IUPACName) out.name = prop.IUPACName;
}
}
if (!out.cas && out.name) {
const url = `https://pubchem.ncbi.nlm.nih.gov/rest/pug/compound/name/${encodeURIComponent(out.name)}/xrefs/RN/JSON`;
const r = await fetch(url, { signal: controller.signal });
if (r.ok) {
const j = await r.json();
const rn = j?.InformationList?.Information?.[0]?.RN?.[0];
if (rn) out.cas = rn;
}
}
} catch (e) {
console.warn('PubChem lookup failed', e);
} finally { clearTimeout(timeout); }
return out;
}
// Wire up events
document.addEventListener('DOMContentLoaded', init);
$('addBtn').addEventListener('click', () => {
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) { status('Provide CAS or name', 'bad'); return; }
addItem(rec);
resetForm(true);
});
$('lookupBtn').addEventListener('click', async () => {
const cas = $('cas').value; const name = $('name').value;
if (!cas && !name) { status('Nothing to look up', 'warn'); return; }
status('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;
status(res.cas || res.name ? 'Filled from PubChem' : 'No match', res.cas||res.name ? 'ok':'warn');
});
$('autoLookup').addEventListener('change', e => {
status(e.target.checked ? 'Autolookup enabled' : 'Autolookup disabled');
});
['cas','name'].forEach(id => {
$(id).addEventListener('blur', async () => {
if (!$('autoLookup').checked) return;
const cas = $('cas').value; const name = $('name').value;
if (!cas || !name) {
const res = await pubchemLookup({cas, name});
if (res.cas && !cas) $('cas').value = res.cas;
if (res.name && !name) $('name').value = res.name;
}
});
});
$('search').addEventListener('input', render);
document.querySelector('#table').addEventListener('click', (e) => {
const btn = e.target.closest('button[data-del]');
if (!btn) return;
const id = Number(btn.getAttribute('data-del'));
if (confirm('Delete entry #' + id + '?')) deleteItem(id);
});
$('clearBtn').addEventListener('click', () => { $('search').value=''; render(); });
$('exportDbBtn').addEventListener('click', exportDb);
$('exportCsvBtn').addEventListener('click', exportCsv);
$('importDb').addEventListener('change', (e) => {
const f = e.target.files?.[0]; if (f) importDb(f);
e.target.value = '';
});
// Keyboard shortcuts: Enter to add, Ctrl+Enter keep focus, '/' for CAS, ',' for Name
document.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (document.activeElement.tagName !== 'TEXTAREA')) {
e.preventDefault();
const keep = e.ctrlKey || e.metaKey;
$('addBtn').click();
if (keep) $('cas').focus();
} else if (e.key === '/') { e.preventDefault(); $('cas').focus(); }
else if (e.key === ',') { e.preventDefault(); $('name').focus(); }
});
</script>
</body>
</html>