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

442
dwg-counting/static/app.js Normal file
View File

@@ -0,0 +1,442 @@
// dwg-counting frontend
(() => {
const $ = (id) => document.getElementById(id);
const sections = {
upload: $("s-upload"),
processing: $("s-processing"),
symbols: $("s-symbols"),
results: $("s-results"),
};
const show = (name) => {
for (const [k, el] of Object.entries(sections)) el.classList.toggle("hidden", k !== name);
};
let state = { jobId: null, symbols: [], selected: new Set(), results: [] };
// Upload handlers
const fileInput = $("file-input");
const dropZone = $("drop-zone");
$("browse-btn").addEventListener("click", () => fileInput.click());
fileInput.addEventListener("change", (e) => e.target.files[0] && upload(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.dataTransfer.files[0] && upload(e.dataTransfer.files[0]));
async function upload(file) {
show("processing");
$("processing-title").textContent = "Připravuji výkres…";
const fd = new FormData();
fd.append("file", file);
try {
// Skip auto-detection by default — user defines symbols manually
const r = await fetch("/api/upload?auto_detect=false", {
method: "POST", body: fd,
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const data = await r.json();
state.jobId = data.job_id;
state.symbols = data.symbols || [];
state.selected = new Set();
renderSymbols();
show("symbols");
} catch (err) {
alert("Chyba: " + err.message);
show("upload");
}
}
$("auto-detect-btn").addEventListener("click", async () => {
if (!state.jobId) return;
show("processing");
$("processing-title").textContent = "Hledám legendu pomocí AI…";
try {
const r = await fetch(`/api/auto-detect/${state.jobId}`, { method: "POST" });
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const data = await r.json();
state.symbols = data.symbols || [];
renderSymbols();
show("symbols");
} catch (err) {
alert("Chyba: " + err.message);
show("symbols");
}
});
$("add-symbol-btn").addEventListener("click", () => {
openAddSymbolModal();
});
$("upload-symbol-btn").addEventListener("click", () => {
$("symbol-file-input").click();
});
$("symbol-file-input").addEventListener("change", async (e) => {
const f = e.target.files[0];
if (!f) return;
const name = prompt("Název symbolu:", f.name.replace(/\.[a-z]+$/i, ""));
if (!name) return;
const fd = new FormData();
fd.append("file", f);
fd.append("description", name);
try {
const r = await fetch(`/api/symbols/${state.jobId}/upload`, { method: "POST", body: fd });
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const sym = await r.json();
state.symbols.push(sym);
renderSymbols();
} catch (err) { alert("Chyba: " + err.message); }
e.target.value = "";
});
function renderSymbols() {
const meta = $("symbols-meta");
if (!state.symbols.length) {
meta.textContent = 'Klikněte „+ Přidat symbol" a vyznačte ve výkresu, co chcete počítat. Nebo zkuste „Auto-detekce z legendy" pro automatický návrh.';
$("symbols-grid").innerHTML = "";
return;
}
meta.textContent = `${state.symbols.length} symbolů. Zaškrtněte k spočítání, ✎ upravit, 🗑 smazat.`;
const grid = $("symbols-grid");
grid.innerHTML = "";
for (const s of state.symbols) {
const card = document.createElement("div");
card.className = "symbol-card";
card.innerHTML = `
<input type="checkbox" data-id="${s.id}">
<img class="symbol-thumb" src="/api/symbol/${state.jobId}/${s.id}?v=${s._v||0}" alt=""
onerror="this.style.visibility='hidden'">
<div class="symbol-info">
<div class="symbol-id">${s.id}</div>
<div class="symbol-desc">${escapeHtml(s.description || "")}</div>
</div>
<button class="symbol-edit" type="button" title="Upravit výřez">✎</button>
<button class="symbol-debug" type="button" title="Debug: zobrazit šablonu a skóre">🔍</button>
<button class="symbol-delete" type="button" title="Smazat symbol">🗑</button>`;
const cb = card.querySelector("input");
cb.addEventListener("change", () => {
if (cb.checked) { state.selected.add(s.id); card.classList.add("selected"); }
else { state.selected.delete(s.id); card.classList.remove("selected"); }
$("count-btn").disabled = state.selected.size === 0;
});
// Click on row toggles checkbox (except on edit btn / thumb)
card.addEventListener("click", (e) => {
if (e.target.closest(".symbol-edit") || e.target === cb) return;
cb.checked = !cb.checked;
cb.dispatchEvent(new Event("change"));
});
card.querySelector(".symbol-edit").addEventListener("click", (e) => {
e.stopPropagation();
openCropModal(s);
});
card.querySelector(".symbol-debug").addEventListener("click", (e) => {
e.stopPropagation();
openDebugModal(s);
});
card.querySelector(".symbol-delete").addEventListener("click", async (e) => {
e.stopPropagation();
if (!confirm(`Smazat symbol "${s.description || s.id}"?`)) return;
try {
const r = await fetch(`/api/symbols/${state.jobId}/${s.id}`, { method: "DELETE" });
if (!r.ok) throw new Error("delete failed");
state.symbols = state.symbols.filter(x => x.id !== s.id);
state.selected.delete(s.id);
renderSymbols();
$("count-btn").disabled = state.selected.size === 0;
} catch (err) { alert("Chyba: " + err.message); }
});
grid.appendChild(card);
}
}
// Add-new-symbol modal: same crop modal, but uses full drawing image and
// asks for a name.
function openAddSymbolModal() {
const modal = $("crop-modal");
const img = $("crop-img");
const sel = $("crop-selection");
const wrap = $("crop-canvas-wrap");
$("crop-modal-title").textContent = "Nový symbol — vyznačte oblast ve výkresu";
$("crop-modal-hint").textContent =
"DOPORUČENO: najděte symbol PŘÍMO ve výkresu (ne v legendě) — barvy a tloušťka čar tam přesně odpovídají tomu, co budeme hledat. Vyznačte těsný rámeček kolem jedné instance symbolu a pojmenujte ho.";
$("crop-name-row").classList.remove("hidden");
$("crop-name").value = "";
img.src = `/api/drawing/${state.jobId}`;
sel.style.display = "none";
$("crop-save").disabled = true;
cropState = { mode: "create", sym: null, bbox: null, dragging: false };
modal.classList.remove("hidden");
attachCropHandlers(wrap, img, sel);
}
// ── Crop modal logic ──────────────────────
let cropState = null;
function openCropModal(sym) {
const modal = $("crop-modal");
const img = $("crop-img");
const sel = $("crop-selection");
const wrap = $("crop-canvas-wrap");
$("crop-modal-title").textContent = `Upravit symbol: ${sym.description || sym.id}`;
$("crop-modal-hint").textContent =
"Označte oblast obsahující pouze grafický symbol (bez popisu).";
$("crop-name-row").classList.add("hidden");
// For editing: use the full drawing so user can pick any instance
img.src = `/api/drawing/${state.jobId}`;
sel.style.display = "none";
$("crop-save").disabled = true;
cropState = { mode: "edit", sym, bbox: null, dragging: false };
modal.classList.remove("hidden");
attachCropHandlers(wrap, img, sel);
return;
}
function attachCropHandlers(wrap, img, sel) {
// Mouse coords relative to the IMAGE's top-left (in image-pixel space,
// which is also wrap's content space since image is at native scale and
// sits at content origin (0,0) in the relative-positioned wrap).
function localXY(e) {
const rect = img.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
}
function onMouseDown(e) {
const p = localXY(e);
cropState.startX = p.x;
cropState.startY = p.y;
cropState.dragging = true;
sel.style.display = "block";
sel.style.left = p.x + "px";
sel.style.top = p.y + "px";
sel.style.width = "0px";
sel.style.height = "0px";
}
function onMouseMove(e) {
if (!cropState || !cropState.dragging) return;
const p = localXY(e);
const left = Math.min(p.x, cropState.startX);
const top = Math.min(p.y, cropState.startY);
const w = Math.abs(p.x - cropState.startX);
const h = Math.abs(p.y - cropState.startY);
sel.style.left = left + "px";
sel.style.top = top + "px";
sel.style.width = w + "px";
sel.style.height = h + "px";
}
function onMouseUp() {
if (!cropState) return;
cropState.dragging = false;
const rect = img.getBoundingClientRect();
const dispW = rect.width, dispH = rect.height;
const left = parseFloat(sel.style.left), top = parseFloat(sel.style.top);
const w = parseFloat(sel.style.width), h = parseFloat(sel.style.height);
if (w < 5 || h < 5) {
sel.style.display = "none";
$("crop-save").disabled = true;
return;
}
cropState.bbox = {
x: left / dispW,
y: top / dispH,
w: w / dispW,
h: h / dispH,
};
$("crop-save").disabled = false;
}
wrap.onmousedown = onMouseDown;
wrap.onmousemove = onMouseMove;
wrap.onmouseup = onMouseUp;
wrap.onmouseleave = onMouseUp;
}
function closeCropModal() {
$("crop-modal").classList.add("hidden");
cropState = null;
}
async function openDebugModal(sym) {
const modal = $("debug-modal");
const body = $("debug-body");
$("debug-title").textContent = `Debug: ${sym.description || sym.id}`;
body.innerHTML = '<p style="padding:20px">Načítám diagnostiku…</p>';
modal.classList.remove("hidden");
try {
const r = await fetch(`/api/debug/${state.jobId}/${sym.id}`);
if (!r.ok) throw new Error(await r.text());
const info = await r.json();
const tmplURL = `/api/symbol/${state.jobId}/${sym.id}?v=${sym._v||0}`;
const procURL = `/api/debug-template/${state.jobId}/${sym.id}?v=${sym._v||0}&t=${Date.now()}`;
const drawURL = `/api/drawing/${state.jobId}`;
const matches = info.matches_at_threshold || {};
const maxMatchCount = Math.max(1, ...Object.values(matches));
const threshRows = Object.entries(matches).map(([t, n]) => `
<div>práh ${t}</div>
<div class="debug-bar"><div class="debug-bar-fill" style="width:${Math.min(100,(n/maxMatchCount)*100)}%"></div></div>
<div>${n} shod</div>`).join("");
const inkPct = info.template_total_pixels
? ((info.template_ink_pixels / info.template_total_pixels) * 100).toFixed(1)
: "?";
body.innerHTML = `
<div class="debug-section">
<h4>Obrazy</h4>
<div class="debug-thumbs">
<div class="debug-thumb">
<a href="${tmplURL}" target="_blank"><img src="${tmplURL}" alt=""></a>
<span>Šablona (uložená)</span>
</div>
<div class="debug-thumb">
<a href="${procURL}" target="_blank"><img src="${procURL}" alt=""></a>
<span>Po předzpracování<br>(co matcher vidí)</span>
</div>
<div class="debug-thumb">
<a href="${drawURL}" target="_blank">otevřít celý výkres ↗</a>
<span>${info.drawing_size.join(" × ")} px</span>
</div>
</div>
</div>
<div class="debug-section">
<h4>Měření</h4>
<div class="debug-kv">
<strong>Šablona originál</strong><span>${info.template_size.join(" × ")} px</span>
<strong>Šablona po ořezu</strong><span>${info.template_cropped_size.join(" × ")} px</span>
<strong>Inkoust v šabloně</strong><span>${info.template_ink_pixels} / ${info.template_total_pixels} px (${inkPct}%)</span>
<strong>Výkres</strong><span>${info.drawing_size.join(" × ")} px</span>
<strong>Nejlepší skóre</strong><span>${info.max_score?.toFixed(3) ?? "—"}</span>
</div>
</div>
<div class="debug-section">
<h4>Shody při různých prazích (bez rotací, scale 1.0)</h4>
<div class="debug-thresholds">${threshRows}</div>
<p style="margin-top:8px; color: var(--text-tertiary); font-size:12px">
Aktuálně používaný práh pro počítání: <strong>0.75</strong>.
Skutečné počty (s rotacemi 0/90/180/270° a třemi scale faktory) jsou typicky nižší kvůli dedupli­kaci.
</p>
</div>
<div class="debug-section" id="debug-interpret"></div>
`;
// Heuristic interpretation
const interpret = [];
if (info.template_ink_pixels < 30)
interpret.push('⚠ Šablona obsahuje příliš málo „inkoustu" — pravděpodobně jste vybrali převážně bílou plochu. Vyznačte rámeček těsně kolem čar symbolu.');
if (Math.min(...info.template_cropped_size) < 12)
interpret.push("⚠ Šablona je velmi malá (<12 px). Malé šablony hlásí spoustu falešných shod nebo žádné. Zkuste přidat trochu okolí.");
if ((info.max_score ?? 0) < 0.5)
interpret.push("⚠ Nejlepší skóre v celém výkresu je velmi nízké — symbol vypadá v plánu jinak než v šabloně. Zkuste vyznačit symbol přímo z plánu (ne z legendy), nebo zkuste jinou variantu/orientaci.");
else if ((info.max_score ?? 0) < 0.7)
interpret.push(" Nejlepší skóre ≈ " + info.max_score.toFixed(2) + ". Práh 0.75 je nad maximem — zkuste snížit práh nebo upravit šablonu těsněji.");
else
interpret.push("✓ Symbol se v plánu vyskytuje. Pokud nejsou shody, může jít o problém s rotacemi nebo měřítkem.");
$("debug-interpret").innerHTML = "<h4>Interpretace</h4>" +
interpret.map(t => `<p>${t}</p>`).join("");
} catch (err) {
body.innerHTML = `<p style="color:#dc2626; padding:12px">Chyba: ${err.message}</p>`;
}
}
$("debug-close").addEventListener("click", () => $("debug-modal").classList.add("hidden"));
$("debug-ok").addEventListener("click", () => $("debug-modal").classList.add("hidden"));
$("crop-close").addEventListener("click", closeCropModal);
$("crop-cancel").addEventListener("click", closeCropModal);
$("crop-save").addEventListener("click", async () => {
if (!cropState || !cropState.bbox) return;
try {
if (cropState.mode === "create") {
const name = ($("crop-name").value || "").trim();
if (!name) { alert("Zadejte název symbolu."); return; }
const r = await fetch(`/api/symbols/${state.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
bbox: cropState.bbox,
description: name,
source: "drawing",
}),
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const newSym = await r.json();
state.symbols.push(newSym);
} else {
const r = await fetch(`/api/symbol/${state.jobId}/${cropState.sym.id}/recrop`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bbox: cropState.bbox }),
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
cropState.sym._v = (cropState.sym._v || 0) + 1;
}
renderSymbols();
closeCropModal();
} catch (err) {
alert("Chyba: " + err.message);
}
});
// Threshold slider live readout
const thrSlider = $("threshold-slider");
const thrValue = $("threshold-value");
thrSlider.addEventListener("input", () => {
thrValue.textContent = parseFloat(thrSlider.value).toFixed(2);
});
$("count-btn").addEventListener("click", async () => {
show("processing");
$("processing-title").textContent = `Počítám ${state.selected.size} symbolů…`;
try {
const r = await fetch(`/api/count/${state.jobId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
symbol_ids: [...state.selected],
threshold: parseFloat(thrSlider.value),
}),
});
if (!r.ok) throw new Error((await r.json()).detail || r.statusText);
const data = await r.json();
state.results = data.results || [];
renderResults();
show("results");
} catch (err) {
alert("Chyba: " + err.message);
show("symbols");
}
});
function renderResults() {
const total = state.results.reduce((a, r) => a + (r.count || 0), 0);
$("results-meta").textContent = `Celkem ${total} symbolů ve ${state.results.length} kategoriích.`;
const tb = $("results-tbody");
tb.innerHTML = "";
for (const r of state.results) {
const tr = document.createElement("tr");
const conf = r.confidence || "";
tr.innerHTML = `
<td><img class="symbol-thumb" style="width:40px;height:40px" src="/api/symbol/${state.jobId}/${r.id}" alt=""></td>
<td>${escapeHtml(r.description || "")}</td>
<td><strong>${r.count}</strong></td>
<td class="confidence-${conf}">${conf || "—"}</td>
<td>${escapeHtml(r.notes || "")}</td>`;
tb.appendChild(tr);
}
}
$("export-btn").addEventListener("click", () => {
window.location.href = `/api/export/${state.jobId}`;
});
$("pdf-btn").addEventListener("click", () => {
window.location.href = `/api/export-pdf/${state.jobId}`;
});
$("back-btn").addEventListener("click", () => show("symbols"));
$("reset-btn").addEventListener("click", () => {
state = { jobId: null, symbols: [], selected: new Set(), results: [] };
fileInput.value = "";
show("upload");
});
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
})();

View File

@@ -0,0 +1,301 @@
/* Extra styles specific to dwg-counting */
.back-link {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px 6px 10px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
color: var(--text-tertiary);
text-decoration: none;
border: 0.5px solid var(--border-default);
background: var(--bg-primary);
transition: color 0.15s, border-color 0.15s, background 0.15s;
flex-shrink: 0;
}
.back-link:hover {
color: var(--primary);
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
}
.back-link svg { opacity: 0.8; }
@media (max-width: 640px) {
.back-link span { display: none; }
.back-link { padding: 6px; }
.back-link svg { width: 16px; height: 16px; }
}
.processing-sub {
font-size: 13px;
color: var(--text-tertiary);
margin-top: 8px;
}
.symbols-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
margin-top: 16px;
}
.symbol-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 12px;
display: flex;
gap: 12px;
align-items: center;
cursor: pointer;
transition: border-color .15s, box-shadow .15s;
}
.symbol-card:hover {
border-color: var(--primary);
}
.symbol-card.selected {
border-color: var(--primary);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 25%, transparent);
}
.symbol-card input[type=checkbox] {
width: 18px;
height: 18px;
flex-shrink: 0;
cursor: pointer;
accent-color: var(--primary);
}
.symbol-thumb {
width: 64px;
height: 64px;
background: #fff;
border: 1px solid var(--border-default);
border-radius: 6px;
object-fit: contain;
padding: 4px;
flex-shrink: 0;
}
.symbol-info {
flex: 1;
min-width: 0;
}
.symbol-id {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
font-weight: 600;
}
.symbol-desc {
font-size: 13px;
color: var(--text-primary);
margin-top: 2px;
line-height: 1.35;
}
.threshold-row {
display: flex;
align-items: center;
gap: 12px;
margin: 12px 0;
padding: 10px 14px;
background: var(--bg-tertiary);
border-radius: 8px;
font-size: 13px;
flex-wrap: wrap;
}
.threshold-row label { font-weight: 600; }
.threshold-row input[type=range] {
flex: 0 0 240px;
accent-color: var(--primary);
}
#threshold-value {
display: inline-block;
min-width: 40px;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--primary);
}
.threshold-hint { color: var(--text-tertiary); font-size: 12px; flex: 1; }
.confidence-high { color: #16a34a; font-weight: 600; }
.confidence-medium { color: #d97706; font-weight: 600; }
.confidence-low { color: #dc2626; font-weight: 600; }
/* Edit button on symbol cards */
.symbol-edit {
background: transparent;
border: 1px solid var(--border-default);
border-radius: 6px;
padding: 4px 8px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
flex-shrink: 0;
}
.symbol-edit:hover {
border-color: var(--primary);
color: var(--primary);
}
/* Crop modal */
.modal {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 100;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: var(--card);
border-radius: var(--radius-lg);
padding: 20px;
max-width: 90vw;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.btn-close {
background: transparent;
border: none;
font-size: 28px;
line-height: 1;
cursor: pointer;
color: var(--text-tertiary);
}
.modal-hint {
font-size: 13px;
color: var(--text-tertiary);
margin-bottom: 12px;
}
.crop-canvas-wrap {
position: relative;
overflow: auto;
max-height: 65vh;
border: 1px solid var(--border-default);
background: #fff;
cursor: crosshair;
}
#crop-img {
display: block;
max-width: none;
user-select: none;
pointer-events: none;
}
#crop-selection {
position: absolute;
border: 2px dashed var(--primary);
background: rgba(21, 90, 239, 0.08);
pointer-events: none;
display: none;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.crop-name-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 10px;
}
.crop-name-row label {
font-size: 13px;
font-weight: 600;
}
.crop-name-row input {
flex: 1;
padding: 6px 10px;
border: 1px solid var(--border-default);
border-radius: 6px;
font-size: 14px;
}
.symbol-delete {
background: transparent;
border: 1px solid transparent;
color: var(--text-tertiary);
font-size: 14px;
padding: 4px 8px;
cursor: pointer;
border-radius: 6px;
flex-shrink: 0;
}
.symbol-delete:hover {
color: #dc2626;
border-color: #dc2626;
}
.symbol-debug {
background: transparent;
border: 1px solid transparent;
color: var(--text-tertiary);
font-size: 14px;
padding: 4px 8px;
cursor: pointer;
border-radius: 6px;
flex-shrink: 0;
}
.symbol-debug:hover {
color: var(--primary);
border-color: var(--primary);
}
.debug-body {
font-size: 13px;
line-height: 1.5;
color: var(--text-primary);
max-height: 70vh;
overflow-y: auto;
}
.debug-section {
margin-bottom: 14px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border-default);
}
.debug-section:last-child { border-bottom: none; }
.debug-section h4 {
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-tertiary);
margin-bottom: 8px;
}
.debug-kv { display: grid; grid-template-columns: 220px 1fr; gap: 4px 12px; }
.debug-kv strong { color: var(--text-secondary); font-weight: 500; }
.debug-thumbs { display: flex; gap: 16px; align-items: flex-start; flex-wrap: wrap; }
.debug-thumb {
display: flex; flex-direction: column; align-items: center;
gap: 4px; min-width: 120px;
}
.debug-thumb a {
display: block;
border: 1px solid var(--border-default);
padding: 4px;
background: #fff;
cursor: zoom-in;
}
.debug-thumb img { display: block; max-width: 160px; max-height: 160px; }
.debug-thumb span { font-size: 11px; color: var(--text-tertiary); }
.debug-thresholds { display: grid; grid-template-columns: 60px 1fr 60px; gap: 4px 10px; align-items: center; }
.debug-bar {
height: 8px;
background: var(--bg-tertiary);
border-radius: 4px;
overflow: hidden;
}
.debug-bar-fill {
height: 100%;
background: var(--primary);
}

View File

@@ -0,0 +1,205 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Počítání symbolů z PDF výkresu | Colsys AI</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&display=swap">
<link rel="stylesheet" href="/static/styles.css">
<link rel="stylesheet" href="/static/extra.css">
<script>
// Theme handling — runs before first paint to prevent flash.
// Priority: URL ?theme= → localStorage → portal_theme cookie → system.
(function () {
var t = null;
try {
var url = new URL(window.location.href);
var p = url.searchParams.get("theme");
if (p === "dark" || p === "light") t = p;
} catch (e) {}
if (!t) {
try { t = localStorage.getItem("app_theme"); } catch (e) {}
}
if (!t) {
var m = document.cookie.match(/(?:^|;\s*)portal_theme=([^;]+)/);
if (m) t = decodeURIComponent(m[1]);
}
if (t === "dark" || t === "light") {
document.documentElement.classList.add(t);
try { localStorage.setItem("app_theme", t); } catch (e) {}
}
})();
</script>
</head>
<body>
<header class="header">
<div class="header-inner">
<a href="https://ai.klas.chat" class="brand">
<span class="brand-icon">C</span>
<span class="brand-name">Colsys <span class="brand-ai">AI</span></span>
</a>
<span class="header-crumb">Počítání symbolů z PDF výkresu</span>
<a href="https://ai.klas.chat" class="back-link" title="Zpět na portál">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6"/>
</svg>
<span>Zpět na portál</span>
</a>
</div>
</header>
<main class="main">
<!-- ── Upload ────────────────────────────── -->
<section id="s-upload">
<div class="section-intro">
<h1 class="section-title">Počítání symbolů z PDF výkresu</h1>
<p class="section-desc">
Nahrajte výkres ve formátu <strong>PDF</strong>, vyznačte symboly, které
chcete spočítat, a aplikace najde jejich výskyty v celém výkresu.
Výsledek lze stáhnout jako Excel nebo jako PDF s vyznačením.
</p>
</div>
<div id="drop-zone" class="drop-zone">
<svg class="drop-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round"
d="M12 16.5V4.5m0 0-3.75 3.75M12 4.5l3.75 3.75M4.5 19.5h15"/>
</svg>
<p class="drop-text">Přetáhněte PDF výkres sem</p>
<p class="drop-or">nebo</p>
<button class="btn btn-secondary" id="browse-btn" type="button">Vybrat soubor</button>
<p class="drop-formats">Podporovaný formát: .pdf</p>
<input type="file" id="file-input" accept=".pdf" style="display:none">
</div>
</section>
<!-- ── Processing ────────────────────────── -->
<section id="s-processing" class="hidden">
<div class="processing-card">
<div class="spinner"></div>
<h2 class="processing-title" id="processing-title">Hledám legendu ve výkresu…</h2>
<p class="processing-sub" id="processing-sub">Toto může trvat 2060 sekund.</p>
</div>
</section>
<!-- ── Symbol selection ──────────────────── -->
<section id="s-symbols" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Vyberte symboly k spočítání</h2>
<p class="results-meta" id="symbols-meta"></p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="reset-btn" type="button">Nahrát jiný</button>
<button class="btn btn-secondary" id="auto-detect-btn" type="button" title="Najít legendu pomocí AI">
Auto-detekce z legendy
</button>
<button class="btn btn-primary" id="add-symbol-btn" type="button">
+ Vyříznout symbol z výkresu
</button>
<button class="btn btn-secondary" id="upload-symbol-btn" type="button">
+ Nahrát PNG
</button>
<input type="file" id="symbol-file-input" accept="image/*" style="display:none">
<button class="btn btn-primary" id="count-btn" type="button" disabled>
Spočítat vybrané
</button>
</div>
</div>
<div class="threshold-row">
<label for="threshold-slider">Citlivost (práh shody):</label>
<input type="range" id="threshold-slider" min="0.40" max="0.95" step="0.01" value="0.70">
<span id="threshold-value">0.70</span>
<span class="threshold-hint">nižší = víc nálezů (i falešných), vyšší = jen přesné shody</span>
</div>
<div class="symbols-grid" id="symbols-grid"></div>
</section>
<!-- ── Debug modal ───────────────────────── -->
<div id="debug-modal" class="modal hidden">
<div class="modal-content" style="max-width:760px">
<div class="modal-header">
<h3 id="debug-title">Debug symbolu</h3>
<button class="btn-close" id="debug-close" type="button">×</button>
</div>
<div id="debug-body" class="debug-body"></div>
<div class="modal-actions">
<button class="btn btn-primary" id="debug-ok" type="button">Zavřít</button>
</div>
</div>
</div>
<!-- ── Crop editor modal ─────────────────── -->
<div id="crop-modal" class="modal hidden">
<div class="modal-content">
<div class="modal-header">
<h3 id="crop-modal-title">Vyznačte symbol</h3>
<button class="btn-close" id="crop-close" type="button">×</button>
</div>
<p class="modal-hint" id="crop-modal-hint">Kliknutím a tažením vyberte oblast obsahující grafický symbol.</p>
<div class="crop-name-row hidden" id="crop-name-row">
<label for="crop-name">Název symbolu:</label>
<input type="text" id="crop-name" placeholder="např. Zásuvka 230V">
</div>
<div class="crop-canvas-wrap" id="crop-canvas-wrap">
<img id="crop-img" alt="" draggable="false">
<div id="crop-selection"></div>
</div>
<div class="modal-actions">
<button class="btn btn-secondary" id="crop-cancel" type="button">Zrušit</button>
<button class="btn btn-primary" id="crop-save" type="button" disabled>Uložit výřez</button>
</div>
</div>
</div>
<!-- ── Results ───────────────────────────── -->
<section id="s-results" class="hidden">
<div class="results-header">
<div>
<h2 class="results-title">Výsledky počítání</h2>
<p class="results-meta" id="results-meta"></p>
</div>
<div class="results-actions">
<button class="btn btn-secondary" id="back-btn" type="button">Zpět na výběr</button>
<button class="btn btn-secondary" id="pdf-btn" type="button">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v12m0 0-3.75-3.75M12 16.5l3.75-3.75M4.5 19.5h15"/>
</svg>
PDF s vyznačením
</button>
<button class="btn btn-primary" id="export-btn" type="button">
<svg class="btn-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v12m0 0-3.75-3.75M12 16.5l3.75-3.75M4.5 19.5h15"/>
</svg>
Exportovat Excel
</button>
</div>
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th style="width:80px">Symbol</th>
<th>Popis</th>
<th style="width:80px">Počet</th>
<th style="width:120px">Spolehlivost</th>
<th>Poznámka</th>
</tr>
</thead>
<tbody id="results-tbody"></tbody>
</table>
</div>
</section>
</main>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,461 @@
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-primary: #ffffff;
--bg-secondary: #f9fafb;
--bg-tertiary: #f2f4f7;
--text-primary: #101828;
--text-secondary: #354052;
--text-tertiary: #676f83;
--text-quaternary: #98a2b2;
--border-default: rgb(16 24 40 / 0.08);
--border-strong: #d0d5dc;
--border-subtle: rgb(16 24 40 / 0.04);
--card: #ffffff;
--primary: #155aef;
--primary-hover: #004aeb;
--accent-indigo: #444ce7;
--shadow-card: 0 1px 2px rgb(16 24 40 / 0.05);
--radius-md: 8px;
--radius-lg: 12px;
}
/* Dark theme — applies when (a) user OS prefers dark and no .light override,
or (b) :root has explicit .dark class (set by portal_theme cookie). */
@media (prefers-color-scheme: dark) {
:root:not(.light) {
--bg-primary: #14181f;
--bg-secondary: #1a1f29;
--bg-tertiary: #232936;
--text-primary: #f5f7fa;
--text-secondary: #c8ccd5;
--text-tertiary: #98a2b2;
--text-quaternary: #676f83;
--border-default: rgb(255 255 255 / 0.08);
--border-strong: #354052;
--border-subtle: rgb(255 255 255 / 0.04);
--card: #1a1f29;
}
}
:root.dark {
--bg-primary: #14181f;
--bg-secondary: #1a1f29;
--bg-tertiary: #232936;
--text-primary: #f5f7fa;
--text-secondary: #c8ccd5;
--text-tertiary: #98a2b2;
--text-quaternary: #676f83;
--border-default: rgb(255 255 255 / 0.08);
--border-strong: #354052;
--border-subtle: rgb(255 255 255 / 0.04);
--card: #1a1f29;
}
body {
font-family: "Geist", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-secondary);
color: var(--text-primary);
min-height: 100vh;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Header ─────────────────────────────────────── */
.header {
position: sticky;
top: 0;
z-index: 30;
border-bottom: 0.5px solid var(--border-default);
background: color-mix(in srgb, var(--bg-secondary) 82%, transparent);
backdrop-filter: blur(8px);
}
.header-inner {
max-width: 1280px; /* match portal max-w-7xl so brand doesn't shift */
margin: 0 auto;
height: 56px; /* portal uses h-14 */
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px; /* portal px-4 */
gap: 16px;
}
@media (min-width: 640px) {
.header-inner { padding: 0 32px; } /* portal sm:px-8 */
}
.brand {
display: flex;
align-items: center;
gap: 10px;
text-decoration: none;
flex-shrink: 0;
}
.brand-icon {
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, var(--primary) 0%, var(--accent-indigo) 100%);
box-shadow: 0 1px 2px rgb(16 24 40 / 0.06), inset 0 1px 0 rgb(255 255 255 / 0.18);
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
letter-spacing: -0.04em;
color: white;
}
.brand-name {
font-size: 14px; /* tailwind text-sm */
font-weight: 600;
letter-spacing: -0.025em; /* tailwind tracking-tight */
color: var(--text-primary);
}
.brand-ai { color: var(--primary); }
.header-crumb {
font-size: 13px;
color: var(--text-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ── Main ────────────────────────────────────────── */
.main {
max-width: 900px;
margin: 0 auto;
padding: 40px 24px 80px;
display: flex;
flex-direction: column;
gap: 32px;
}
@media (max-width: 640px) {
.header-inner { padding: 0 16px; }
.main { padding: 24px 16px 60px; }
}
/* ── Section intro ───────────────────────────────── */
.section-intro { margin-bottom: 24px; }
.section-title {
font-size: 22px;
font-weight: 600;
letter-spacing: -0.02em;
margin-bottom: 8px;
}
.section-desc {
color: var(--text-tertiary);
max-width: 580px;
font-size: 14px;
line-height: 1.6;
}
/* ── Examples panel ──────────────────────────────── */
.examples-panel {
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
background: var(--card);
box-shadow: var(--shadow-card);
padding: 16px;
margin-bottom: 16px;
}
.examples-header {
display: flex;
flex-direction: column;
gap: 3px;
margin-bottom: 12px;
}
.examples-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.examples-subtitle {
font-size: 12px;
color: var(--text-tertiary);
line-height: 1.5;
}
.examples-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
min-height: 28px;
margin-bottom: 10px;
}
.examples-list:empty::before {
content: "Zatím žádné vzory — přidejte alespoň jeden níže";
font-size: 12px;
color: var(--text-quaternary);
font-style: italic;
padding: 6px 0;
}
.example-chip {
display: inline-flex;
align-items: center;
gap: 6px;
background: color-mix(in srgb, var(--primary) 10%, var(--bg-primary));
border: 1px solid color-mix(in srgb, var(--primary) 30%, transparent);
color: var(--text-primary);
padding: 4px 4px 4px 10px;
border-radius: 999px;
font-size: 12px;
font-family: ui-monospace, monospace;
}
.example-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
background: transparent;
border: none;
cursor: pointer;
color: var(--text-tertiary);
padding: 0;
transition: background 0.1s, color 0.1s;
}
.example-chip-remove:hover {
background: color-mix(in srgb, var(--primary) 18%, transparent);
color: var(--text-primary);
}
.example-chip-remove svg {
width: 12px;
height: 12px;
}
.examples-input-row {
display: flex;
gap: 8px;
}
.example-input {
flex: 1;
font-family: ui-monospace, monospace;
font-size: 13px;
padding: 8px 12px;
border: 1px solid var(--border-strong);
border-radius: var(--radius-md);
background: var(--bg-primary);
color: var(--text-primary);
min-width: 0;
}
.example-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 18%, transparent);
}
.example-add-btn {
flex-shrink: 0;
}
/* ── Drop zone ───────────────────────────────────── */
.drop-zone {
border: 1.5px dashed var(--border-strong);
border-radius: var(--radius-lg);
background: var(--card);
padding: 56px 32px;
text-align: center;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
box-shadow: var(--shadow-card);
}
.drop-zone:hover, .drop-zone.drag-over {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 4%, var(--card));
}
.drop-icon {
width: 44px;
height: 44px;
color: var(--text-quaternary);
margin: 0 auto 18px;
display: block;
transition: color 0.15s;
}
.drop-zone:hover .drop-icon, .drop-zone.drag-over .drop-icon {
color: var(--primary);
}
.drop-text {
font-size: 15px;
font-weight: 500;
color: var(--text-secondary);
margin-bottom: 6px;
}
.drop-or {
font-size: 13px;
color: var(--text-quaternary);
margin-bottom: 14px;
}
.drop-formats {
font-size: 12px;
color: var(--text-quaternary);
margin-top: 14px;
}
/* ── Buttons ─────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: var(--radius-md);
font-size: 13px;
font-weight: 500;
cursor: pointer;
border: none;
transition: background 0.15s, box-shadow 0.15s;
line-height: 1;
}
.btn-primary {
background: var(--primary);
color: #fff;
box-shadow: 0 1px 2px rgb(21 90 239 / 0.25);
}
.btn-primary:hover { background: var(--primary-hover); }
.btn-primary:disabled { opacity: 0.5; cursor: default; }
.btn-secondary {
background: var(--bg-tertiary);
color: var(--text-secondary);
border: 1px solid var(--border-strong);
}
.btn-secondary:hover { background: var(--border-strong); }
.btn-icon { width: 15px; height: 15px; flex-shrink: 0; }
/* ── Processing card ─────────────────────────────── */
.processing-card {
background: var(--card);
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
padding: 56px 32px;
text-align: center;
box-shadow: var(--shadow-card);
}
.spinner {
width: 36px;
height: 36px;
border: 3px solid var(--border-strong);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.75s linear infinite;
margin: 0 auto 22px;
}
@keyframes spin { to { transform: rotate(360deg); } }
.processing-title {
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
color: var(--text-primary);
}
.steps-list {
display: flex;
flex-direction: column;
gap: 8px;
max-width: 320px;
margin: 0 auto;
text-align: left;
}
.step-item {
display: flex;
align-items: center;
gap: 10px;
font-size: 13px;
color: var(--text-tertiary);
padding: 8px 12px;
border-radius: var(--radius-md);
background: var(--bg-tertiary);
}
.step-item.active {
color: var(--primary);
background: color-mix(in srgb, var(--primary) 8%, var(--bg-primary));
}
.step-item.done { color: #17b26a; }
.step-item.error { color: #d92d20; background: #fef3f2; }
.step-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: currentColor;
flex-shrink: 0;
}
/* ── Results ─────────────────────────────────────── */
.results-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.results-title {
font-size: 18px;
font-weight: 600;
letter-spacing: -0.015em;
margin-bottom: 4px;
}
.results-meta {
font-size: 13px;
color: var(--text-tertiary);
}
.results-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* ── Table ───────────────────────────────────────── */
.table-wrap {
border: 1px solid var(--border-default);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-card);
background: var(--card);
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
min-width: 500px;
}
thead {
background: var(--bg-tertiary);
border-bottom: 1px solid var(--border-default);
}
th {
padding: 10px 14px;
text-align: left;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-tertiary);
white-space: nowrap;
}
td {
padding: 8px 14px;
border-bottom: 1px solid var(--border-subtle);
color: var(--text-primary);
vertical-align: middle;
}
tr:last-child td { border-bottom: none; }
tr:nth-child(even) td { background: var(--bg-secondary); }
td[contenteditable]:focus {
outline: none;
background: color-mix(in srgb, var(--primary) 6%, var(--bg-primary));
box-shadow: inset 0 0 0 1.5px var(--primary);
border-radius: 3px;
}
.badge {
display: inline-block;
padding: 2px 8px;
border-radius: 99px;
font-size: 11px;
font-weight: 500;
}
.badge-rule { background: #d1e0ff; color: #004aeb; }
.badge-llm { background: #d1fae5; color: #065f46; }
@media (prefers-color-scheme: dark) {
.badge-rule { background: #1e3a8a; color: #93c5fd; }
.badge-llm { background: #064e3b; color: #6ee7b7; }
}
.table-hint {
font-size: 12px;
color: var(--text-quaternary);
margin-top: 8px;
}
.hidden { display: none !important; }