Initial portal commit: landing + 9 AI-powered apps

Apps:
- dwg-rooms: extract room numbers from DWG/DXF
- dwg-counting: count symbols in PDF drawings (OpenCV template matching)
- contract-check: review PDF contracts against a checklist (Claude vision + Tesseract OCR fallback)
- email-drafter: bullet notes → polished Czech/English business emails
- invoice-extractor: PDF/image invoice → structured data → Excel
- translator: Czech-first translator across 19 languages with tone control
- vv-check: find inconsistent unit prices across VV sheets in one workbook
- vv-compare: diff original vs new VV files (changes / added / removed)
- feature-request: portal users submit ideas + sample files

Infrastructure:
- LiteLLM gateway with per-app virtual keys + budgets
- Langfuse observability
- Geist font, shared theme, cross-subdomain back link + theme sync via cookie/URL
- Caddy reverse proxy on *.klas.chat
This commit is contained in:
Ondřej Glaser
2026-05-13 15:25:04 +02:00
commit 48cef99257
139 changed files with 20171 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
// Feature request form
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
form: $("s-form"),
processing: $("s-processing"),
thanks: $("s-thanks"),
};
const show = (name) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
};
// Persist name + email for next submission
for (const k of ["name", "email"]) {
const v = localStorage.getItem("req_" + k);
if (v) $(k).value = v;
$(k).addEventListener("input", () => localStorage.setItem("req_" + k, $(k).value));
}
// ── File handling ──
const fileInput = $("file-input");
const dropZone = $("file-drop");
const dropText = $("file-drop-text");
let attachedFile = null;
function setFile(f) {
if (!f) return;
attachedFile = f;
const size = f.size > 1024 * 1024
? `${(f.size / 1024 / 1024).toFixed(1)} MB`
: `${(f.size / 1024).toFixed(0)} kB`;
dropZone.classList.add("has-file");
dropText.innerHTML = `
<span class="file-pill">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
${escapeHtml(f.name)} <span style="color:var(--text-tertiary)">(${size})</span>
<button type="button" class="file-pill-clear" id="file-clear">×</button>
</span>
`;
$("file-clear").addEventListener("click", clearFile);
}
function clearFile() {
attachedFile = null;
fileInput.value = "";
dropZone.classList.remove("has-file");
dropText.innerHTML = `Přetáhněte soubor sem nebo
<button type="button" class="btn-link" id="file-pick-btn">vyberte z disku</button>`;
$("file-pick-btn").addEventListener("click", () => fileInput.click());
}
$("file-pick-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => setFile(e.target.files[0]));
["dragenter", "dragover"].forEach((ev) =>
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add("drag-over"); }));
["dragleave", "drop"].forEach((ev) =>
dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove("drag-over"); }));
dropZone.addEventListener("drop", (e) => { e.preventDefault(); setFile(e.dataTransfer.files[0]); });
// ── Submit ──
$("request-form").addEventListener("submit", async (e) => {
e.preventDefault();
show("processing");
try {
const fd = new FormData();
fd.append("title", $("title").value);
fd.append("description", $("description").value);
fd.append("name", $("name").value);
fd.append("email", $("email").value);
if (attachedFile) fd.append("file", attachedFile);
const r = await fetch("/api/submit", { method: "POST", body: fd });
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
show("thanks");
} catch (err) {
alert("Chyba: " + err.message);
show("form");
}
});
$("another-btn").addEventListener("click", () => {
$("title").value = "";
$("description").value = "";
clearFile();
show("form");
});
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
})();