385 lines
16 KiB
HTML
385 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 (Offline‑friendly)</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" /> Auto‑lookup 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 ? 'Auto‑lookup enabled' : 'Auto‑lookup 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>
|