Add this old crap so I can deploy it on Coolify

This commit is contained in:
2026-05-10 14:10:45 +02:00
parent 5eb5fe9507
commit 80b9a617a7

384
index.html Normal file
View File

@ -0,0 +1,384 @@
<!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>