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:
442
dwg-counting/static/app.js
Normal file
442
dwg-counting/static/app.js
Normal 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 deduplikaci.
|
||||
</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) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
})();
|
||||
301
dwg-counting/static/extra.css
Normal file
301
dwg-counting/static/extra.css
Normal 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);
|
||||
}
|
||||
205
dwg-counting/static/index.html
Normal file
205
dwg-counting/static/index.html
Normal 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 20–60 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>
|
||||
461
dwg-counting/static/styles.css
Normal file
461
dwg-counting/static/styles.css
Normal 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; }
|
||||
Reference in New Issue
Block a user