Migrate to PostgreSQL + add FastAPI webapp for Coolify deploy
Backend migration: - Replace pyodbc/SQL Server with psycopg2/PostgreSQL throughout - Rewrite Database class with portable SQL: SERIAL, ON CONFLICT, NOW() - Lowercase table names (rip_help_files, rip_help_sections) - Postgres convention - libpq connection string format in HELP_DB_CONN Webapp (webapp/): - FastAPI app: GET /, GET /images/<f>, GET /home-image, GET /api/sections, POST /api/keywords/<code>, GET /healthz - Jinja2 template extracted from generate_html.py with HTTP image URLs - Direct keyword save to DB (no JSON download detour) - Same prefix scoping as CLI tools (?prefix=RIP) Deployment: - Dockerfile (python:3.12-slim + uvicorn) - docker-compose.yml for local dev - requirements-webapp.txt (minimal, no Windows-only deps) - .dockerignore excludes pipeline scripts and BAT files - README updated with webapp section and Coolify deploy guide Also: switch AI model to claude-haiku-4-5 (~3x cheaper, same quality for this task) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
576
webapp/templates/viewer.html
Normal file
576
webapp/templates/viewer.html
Normal file
@@ -0,0 +1,576 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="bg">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RIP Help Viewer{% if prefix %} — {{ prefix }}{% endif %}</title>
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@300;400;500&display=swap');
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #f5f6f8;
|
||||
--bg2: #ffffff;
|
||||
--bg3: #eef0f4;
|
||||
--border: #d8dce3;
|
||||
--accent: #3a6fcf;
|
||||
--accent2: #2ca36f;
|
||||
--text: #1a1a1f;
|
||||
--muted: #6a6a78;
|
||||
--danger: #c84545;
|
||||
--tag-bg: #e7efff;
|
||||
--tag-text: #2c5cb8;
|
||||
--mono: 'IBM Plex Mono', monospace;
|
||||
--sans: 'IBM Plex Sans', sans-serif;
|
||||
--radius: 6px;
|
||||
--radius-lg: 12px;
|
||||
}
|
||||
|
||||
body { background: var(--bg); color: var(--text); font-family: var(--sans); font-size: 14px; min-height: 100vh; }
|
||||
|
||||
header {
|
||||
display: flex; align-items: center; gap: 16px; padding: 14px 24px;
|
||||
border-bottom: 1px solid #1f3a6f;
|
||||
background: linear-gradient(90deg, #2d5fb0 0%, #3a6fcf 55%, #5589dd 100%);
|
||||
color: #ffffff; box-shadow: 0 2px 6px rgba(0,0,0,.10);
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
}
|
||||
header h1 {
|
||||
font-family: var(--mono); font-size: 16px; font-weight: 600; color: #ffffff;
|
||||
letter-spacing: .05em; padding: 4px 12px;
|
||||
background: rgba(255,255,255,.14); border: 1px solid rgba(255,255,255,.28);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
header .sep { color: rgba(255,255,255,.35); }
|
||||
header .pfx { font-family: var(--mono); font-size: 12px; color: rgba(255,255,255,.85); }
|
||||
.gen-time { font-size: 11px; color: rgba(255,255,255,.80); margin-left: auto; font-family: var(--mono); }
|
||||
|
||||
.tabs { display: flex; gap: 2px; padding: 0 24px; background: var(--bg2); border-bottom: 1px solid var(--border); }
|
||||
.tab {
|
||||
padding: 10px 20px; font-size: 13px; font-family: var(--mono); color: var(--muted);
|
||||
cursor: pointer; border-bottom: 2px solid transparent;
|
||||
transition: color .15s, border-color .15s; user-select: none;
|
||||
}
|
||||
.tab.active { color: var(--accent); border-color: var(--accent); }
|
||||
.tab:hover:not(.active) { color: var(--text); }
|
||||
|
||||
.panel { display: none; padding: 20px 24px; }
|
||||
.panel.active { display: block; }
|
||||
|
||||
.toolbar { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
|
||||
input[type=text], textarea {
|
||||
background: var(--bg3); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
color: var(--text); font-family: var(--sans); font-size: 13px; padding: 7px 12px;
|
||||
outline: none; transition: border-color .15s;
|
||||
}
|
||||
input[type=text]:focus, textarea:focus { border-color: var(--accent); }
|
||||
.search-box { flex: 1; min-width: 220px; }
|
||||
button {
|
||||
padding: 7px 16px; border-radius: var(--radius); border: none;
|
||||
font-family: var(--mono); font-size: 12px; cursor: pointer; transition: opacity .15s;
|
||||
}
|
||||
button:hover { opacity: .85; }
|
||||
.btn-primary { background: var(--accent); color: #fff; }
|
||||
.btn-success { background: var(--accent2); color: #fff; }
|
||||
.btn-danger { background: var(--danger); color: #fff; }
|
||||
.btn-ghost { background: var(--bg3); color: var(--text); border: 1px solid var(--border); }
|
||||
|
||||
.stats { font-size: 11px; color: var(--muted); font-family: var(--mono); }
|
||||
|
||||
.tbl-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
thead th {
|
||||
background: var(--bg3); padding: 10px 12px; text-align: left;
|
||||
font-family: var(--mono); font-size: 11px; font-weight: 500; color: var(--muted);
|
||||
letter-spacing: .06em; text-transform: uppercase;
|
||||
border-bottom: 1px solid var(--border); white-space: nowrap;
|
||||
}
|
||||
tbody tr { border-bottom: 1px solid var(--border); transition: background .1s; }
|
||||
tbody tr:last-child { border-bottom: none; }
|
||||
tbody tr:hover { background: var(--bg3); }
|
||||
td { padding: 8px 12px; vertical-align: top; }
|
||||
|
||||
.code-badge {
|
||||
font-family: var(--mono); font-size: 11px; color: var(--accent);
|
||||
background: rgba(58,111,207,.10); padding: 2px 7px; border-radius: 4px; white-space: nowrap;
|
||||
}
|
||||
|
||||
.kw-cell { display: flex; flex-direction: column; gap: 6px; min-width: 280px; }
|
||||
.kw-tags { display: flex; flex-wrap: wrap; gap: 4px; min-height: 18px; }
|
||||
.tag {
|
||||
font-family: var(--mono); font-size: 11px;
|
||||
background: var(--tag-bg); color: var(--tag-text);
|
||||
padding: 2px 8px; border-radius: 20px; white-space: nowrap;
|
||||
}
|
||||
.kw-edit-row { display: flex; gap: 6px; align-items: center; }
|
||||
.kw-input {
|
||||
flex: 1; min-width: 180px;
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
color: var(--text); padding: 5px 10px; font-size: 12px; font-family: var(--sans);
|
||||
}
|
||||
.kw-input:focus {
|
||||
border-color: var(--accent); background: var(--bg2);
|
||||
box-shadow: 0 0 0 2px rgba(58,111,207,.15); outline: none;
|
||||
}
|
||||
.kw-input.changed { border-color: var(--accent2); background: rgba(44,163,111,.05); }
|
||||
.kw-input.saving { border-color: var(--accent); background: rgba(58,111,207,.05); }
|
||||
.kw-input.saved { border-color: var(--accent2); background: rgba(44,163,111,.10); }
|
||||
.kw-input.error { border-color: var(--danger); background: rgba(200,69,69,.05); }
|
||||
|
||||
.save-btn {
|
||||
padding: 5px 12px; font-size: 12px;
|
||||
background: var(--accent2); color: #fff;
|
||||
border-radius: var(--radius); border: none; cursor: pointer;
|
||||
display: none; font-family: var(--mono);
|
||||
}
|
||||
.save-btn.visible { display: inline-block; }
|
||||
.save-btn:hover { opacity: .9; }
|
||||
|
||||
.src-file { font-size: 11px; color: var(--muted); font-family: var(--mono); max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.title-cell { max-width: 220px; }
|
||||
|
||||
.results-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 12px; }
|
||||
.card {
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||
padding: 14px 16px; transition: border-color .15s; cursor: pointer;
|
||||
}
|
||||
.card:hover { border-color: var(--accent); }
|
||||
.card.selected { border-color: var(--accent2); background: rgba(44,163,111,.10); }
|
||||
.card-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; margin-bottom: 8px; }
|
||||
.card-title { font-weight: 500; font-size: 13px; line-height: 1.4; }
|
||||
.card-tags { display: flex; flex-wrap: wrap; gap: 4px; margin-bottom: 8px; }
|
||||
.card-text { font-size: 12px; color: var(--muted); line-height: 1.6; max-height: 280px; overflow: hidden; }
|
||||
.card-text img { max-width: 100%; max-height: 200px; display: block; margin: 6px 0; border-radius: 4px; border: 1px solid var(--border); }
|
||||
.card-footer { margin-top: 8px; font-size: 11px; color: var(--muted); font-family: var(--mono); }
|
||||
.check-icon { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--border); flex-shrink: 0; margin-top: 2px; transition: all .15s; }
|
||||
.card.selected .check-icon { background: var(--accent2); border-color: var(--accent2); }
|
||||
|
||||
.gen-layout { display: grid; grid-template-columns: 1fr 280px; gap: 20px; }
|
||||
.selected-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.sel-item {
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 10px 14px; display: flex; align-items: center; gap: 10px; cursor: grab;
|
||||
}
|
||||
.sel-item:active { cursor: grabbing; }
|
||||
.sel-item.drag-over { border-color: var(--accent); background: rgba(58,111,207,.10); }
|
||||
.drag-handle { color: var(--muted); font-size: 16px; user-select: none; }
|
||||
.sel-item-info { flex: 1; }
|
||||
.sel-item-title { font-size: 13px; font-weight: 500; }
|
||||
.sel-item-code { font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
||||
.remove-btn { background: none; border: none; color: var(--muted); font-size: 16px; cursor: pointer; padding: 0 4px; }
|
||||
.remove-btn:hover { color: var(--danger); }
|
||||
|
||||
.gen-panel {
|
||||
background: var(--bg2); border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||
padding: 20px; position: sticky; top: 80px;
|
||||
}
|
||||
.gen-panel h3 { font-family: var(--mono); font-size: 13px; color: var(--muted); margin-bottom: 16px; letter-spacing: .06em; }
|
||||
.format-btns { display: flex; flex-direction: column; gap: 8px; }
|
||||
.format-btn {
|
||||
padding: 10px 16px; border-radius: var(--radius); border: 1px solid var(--border);
|
||||
background: var(--bg3); color: var(--text); font-family: var(--mono); font-size: 12px;
|
||||
cursor: pointer; text-align: left; transition: all .15s;
|
||||
display: flex; align-items: center; gap: 10px;
|
||||
}
|
||||
.format-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.format-dot { width: 8px; height: 8px; border-radius: 50%; }
|
||||
.dot-word { background: #2b7fd4; }
|
||||
.dot-html { background: #e0854a; }
|
||||
.dot-pdf { background: #e05c5c; }
|
||||
|
||||
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
|
||||
.empty-state .big { font-size: 40px; margin-bottom: 12px; }
|
||||
|
||||
#toast {
|
||||
position: fixed; bottom: 24px; right: 24px;
|
||||
background: var(--accent2); color: #fff;
|
||||
padding: 10px 20px; border-radius: var(--radius);
|
||||
font-family: var(--mono); font-size: 13px;
|
||||
opacity: 0; transform: translateY(10px);
|
||||
transition: all .3s; pointer-events: none; z-index: 999;
|
||||
}
|
||||
#toast.show { opacity: 1; transform: translateY(0); }
|
||||
#toast.error { background: var(--danger); }
|
||||
|
||||
.sep { color: var(--border); margin: 0 4px; }
|
||||
|
||||
.home-wrap { display: flex; justify-content: center; align-items: flex-start; padding: 24px; }
|
||||
.home-wrap img {
|
||||
max-width: 100%; max-height: calc(100vh - 200px);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.06);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>BG16RFPR001-1.001-0068</h1>
|
||||
<span class="sep">|</span>
|
||||
<span class="pfx" id="total-count"></span>
|
||||
{% if prefix %}<span class="sep">|</span><span class="pfx">prefix={{ prefix }}</span>{% endif %}
|
||||
<span class="gen-time">генериран: {{ generated }}</span>
|
||||
</header>
|
||||
|
||||
<div class="tabs">
|
||||
{% if home_url %}<div class="tab active" onclick="switchTab('home')">00 / Home</div>{% endif %}
|
||||
<div class="tab {% if not home_url %}active{% endif %}" onclick="switchTab('editor')">01 / Редактор</div>
|
||||
<div class="tab" onclick="switchTab('search')">02 / Търсене</div>
|
||||
<div class="tab" onclick="switchTab('generator')">03 / Генератор</div>
|
||||
</div>
|
||||
|
||||
{% if home_url %}
|
||||
<div id="tab-home" class="panel active">
|
||||
<div class="home-wrap"><img src="{{ home_url }}" alt="Home"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div id="tab-editor" class="panel {% if not home_url %}active{% endif %}">
|
||||
<div class="toolbar">
|
||||
<input type="text" class="search-box" id="editor-search" placeholder="Филтрирай по код, заглавие, ключова дума..." oninput="filterEditor()">
|
||||
<span class="stats" id="editor-stats"></span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table id="editor-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Код</th>
|
||||
<th>Заглавие</th>
|
||||
<th>Ключови думи</th>
|
||||
<th>Source файл</th>
|
||||
<th>Обновен</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="editor-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-search" class="panel">
|
||||
<div class="toolbar">
|
||||
<input type="text" class="search-box" id="search-input"
|
||||
placeholder='Няколко думи разделени с интервал (AND); за фраза - "в кавички"'
|
||||
oninput="doSearch()">
|
||||
<span class="stats" id="search-stats"></span>
|
||||
<button class="btn-primary" onclick="addSelectedToGenerator()">Добави избраните → Генератор</button>
|
||||
</div>
|
||||
<div class="results-grid" id="search-results"></div>
|
||||
</div>
|
||||
|
||||
<div id="tab-generator" class="panel">
|
||||
<div class="gen-layout">
|
||||
<div>
|
||||
<div class="toolbar">
|
||||
<span class="stats" id="gen-stats">Няма избрани секции</span>
|
||||
<button class="btn-ghost" onclick="clearGenerator()">Изчисти</button>
|
||||
</div>
|
||||
<div class="selected-list" id="selected-list">
|
||||
<div class="empty-state">
|
||||
<div class="big">⬡</div>
|
||||
<div>Избери секции от таб Търсене</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gen-panel">
|
||||
<h3>ГЕНЕРИРАЙ ДОКУМЕНТ</h3>
|
||||
<div class="format-btns">
|
||||
<button class="format-btn" onclick="generateDoc('html')">
|
||||
<span class="format-dot dot-html"></span> HTML файл (нов tab)
|
||||
</button>
|
||||
<button class="format-btn" onclick="generateDoc('docx')">
|
||||
<span class="format-dot dot-word"></span> Word (.docx) — TODO
|
||||
</button>
|
||||
<button class="format-btn" onclick="generateDoc('pdf')">
|
||||
<span class="format-dot dot-pdf"></span> PDF — TODO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
const ALL = {{ sections_json | safe }};
|
||||
|
||||
document.getElementById('total-count').textContent = ALL.length + ' секции';
|
||||
renderEditor(ALL);
|
||||
doSearch();
|
||||
renderGenerator();
|
||||
|
||||
function switchTab(name) {
|
||||
const order = [{% if home_url %}'home',{% endif %}'editor','search','generator'];
|
||||
document.querySelectorAll('.tab').forEach((t,i) => t.classList.toggle('active', order[i] === name));
|
||||
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
||||
document.getElementById('tab-' + name).classList.add('active');
|
||||
if (name === 'generator') renderGenerator();
|
||||
}
|
||||
|
||||
// ── EDITOR ────────────────────────────
|
||||
function renderEditor(rows) {
|
||||
const tbody = document.getElementById('editor-body');
|
||||
tbody.innerHTML = rows.map(r => `
|
||||
<tr>
|
||||
<td><span class="code-badge">${r.code}</span></td>
|
||||
<td class="title-cell">${esc(r.title)}</td>
|
||||
<td>
|
||||
<div class="kw-cell">
|
||||
<div class="kw-tags" id="tags-${r.code}">
|
||||
${(r.keywords||'').split(',').filter(k=>k.trim()).map(k=>`<span class="tag">${esc(k.trim())}</span>`).join('')}
|
||||
</div>
|
||||
<div class="kw-edit-row">
|
||||
<input class="kw-input" data-code="${r.code}" value="${esc(r.keywords||'')}"
|
||||
placeholder="ключови думи, разделени със запетая"
|
||||
oninput="onKwChange(this)"
|
||||
onkeydown="if(event.key==='Enter'){saveOne(this);event.preventDefault();}">
|
||||
<button class="save-btn" id="sb-${r.code}"
|
||||
onclick="saveOne(document.querySelector(\`[data-code='${r.code}']\`))">✓ Запази</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><span class="src-file" title="${esc(r.source_file)}">${esc(shortPath(r.source_file))}</span></td>
|
||||
<td style="font-size:11px;color:var(--muted);font-family:var(--mono);white-space:nowrap">${r.updated_at}</td>
|
||||
</tr>
|
||||
`).join('');
|
||||
document.getElementById('editor-stats').textContent = rows.length + ' реда';
|
||||
}
|
||||
|
||||
function filterEditor() {
|
||||
const q = document.getElementById('editor-search').value.toLowerCase();
|
||||
const filtered = q ? ALL.filter(r =>
|
||||
(r.code||'').toLowerCase().includes(q) ||
|
||||
(r.title||'').toLowerCase().includes(q) ||
|
||||
(r.keywords||'').toLowerCase().includes(q) ||
|
||||
(r.source_file||'').toLowerCase().includes(q)
|
||||
) : ALL;
|
||||
renderEditor(filtered);
|
||||
}
|
||||
|
||||
function onKwChange(inp) {
|
||||
const code = inp.dataset.code;
|
||||
inp.classList.add('changed');
|
||||
inp.classList.remove('saved', 'error');
|
||||
document.getElementById('sb-' + code).classList.add('visible');
|
||||
const tagsHost = document.getElementById('tags-' + code);
|
||||
if (tagsHost) {
|
||||
tagsHost.innerHTML = inp.value.split(',')
|
||||
.filter(k => k.trim())
|
||||
.map(k => `<span class="tag">${esc(k.trim())}</span>`)
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
async function saveOne(inp) {
|
||||
const code = inp.dataset.code;
|
||||
const keywords = inp.value;
|
||||
const btn = document.getElementById('sb-' + code);
|
||||
inp.classList.remove('changed', 'saved', 'error');
|
||||
inp.classList.add('saving');
|
||||
try {
|
||||
const res = await fetch(`/api/keywords/${encodeURIComponent(code)}`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({keywords})
|
||||
});
|
||||
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||
inp.classList.remove('saving');
|
||||
inp.classList.add('saved');
|
||||
if (btn) btn.classList.remove('visible');
|
||||
const row = ALL.find(r => r.code === code);
|
||||
if (row) row.keywords = keywords;
|
||||
toast(`Запазено: ${code}`);
|
||||
setTimeout(() => inp.classList.remove('saved'), 1500);
|
||||
} catch (e) {
|
||||
inp.classList.remove('saving');
|
||||
inp.classList.add('error');
|
||||
toast(`Грешка: ${e.message}`, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ── SEARCH ────────────────────────────
|
||||
const selected = new Set();
|
||||
let genOrder = [];
|
||||
|
||||
function tokenizeQuery(q) {
|
||||
const tokens = [];
|
||||
const re = /"([^"]+)"|(\S+)/g;
|
||||
let m;
|
||||
while ((m = re.exec(q)) !== null) {
|
||||
const t = (m[1] || m[2] || '').toLowerCase();
|
||||
if (t) tokens.push(t);
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function doSearch() {
|
||||
const q = document.getElementById('search-input').value.trim().toLowerCase();
|
||||
const tokens = q ? tokenizeQuery(q) : [];
|
||||
const results = tokens.length === 0 ? ALL : ALL.filter(r => {
|
||||
const hay = ((r.keywords||'') + ' ' + (r.title||'') + ' ' + (r.text||'')).toLowerCase();
|
||||
return tokens.every(t => hay.includes(t));
|
||||
});
|
||||
document.getElementById('search-stats').textContent =
|
||||
results.length + ' резултата' + (tokens.length > 1 ? ' (за ' + tokens.length + ' термина)' : '');
|
||||
document.getElementById('search-results').innerHTML = results.map(r => `
|
||||
<div class="card ${selected.has(r.code)?'selected':''}" onclick="toggleSelect('${r.code}', this)">
|
||||
<div class="card-header">
|
||||
<div>
|
||||
<div class="code-badge" style="margin-bottom:6px">${r.code}</div>
|
||||
<div class="card-title">${esc(r.title)}</div>
|
||||
</div>
|
||||
<div class="check-icon"></div>
|
||||
</div>
|
||||
<div class="card-tags">
|
||||
${(r.keywords||'').split(',').filter(k=>k.trim()).map(k=>`<span class="tag">${esc(k.trim())}</span>`).join('')}
|
||||
</div>
|
||||
<div class="card-text">${r.text_html || esc(r.text||'(няма preview)')}</div>
|
||||
<div class="card-footer">${esc(shortPath(r.source_file))} · ${r.char_count} знака</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function toggleSelect(code, el) {
|
||||
if (selected.has(code)) {
|
||||
selected.delete(code);
|
||||
el.classList.remove('selected');
|
||||
genOrder = genOrder.filter(c => c !== code);
|
||||
} else {
|
||||
selected.add(code);
|
||||
el.classList.add('selected');
|
||||
genOrder.push(code);
|
||||
}
|
||||
}
|
||||
|
||||
function addSelectedToGenerator() {
|
||||
if (selected.size === 0) { toast('Избери поне една секция'); return; }
|
||||
switchTab('generator');
|
||||
}
|
||||
|
||||
// ── GENERATOR ─────────────────────────
|
||||
function renderGenerator() {
|
||||
const list = document.getElementById('selected-list');
|
||||
const stats = document.getElementById('gen-stats');
|
||||
if (genOrder.length === 0) {
|
||||
list.innerHTML = '<div class="empty-state"><div class="big">⬡</div><div>Избери секции от таб Търсене</div></div>';
|
||||
stats.textContent = 'Няма избрани секции';
|
||||
return;
|
||||
}
|
||||
stats.textContent = genOrder.length + ' секции избрани';
|
||||
list.innerHTML = genOrder.map((code, idx) => {
|
||||
const r = ALL.find(x => x.code === code);
|
||||
if (!r) return '';
|
||||
return `
|
||||
<div class="sel-item" draggable="true" data-idx="${idx}"
|
||||
ondragstart="dragStart(event,${idx})"
|
||||
ondragover="dragOver(event,${idx})"
|
||||
ondrop="dragDrop(event,${idx})"
|
||||
ondragleave="this.classList.remove('drag-over')">
|
||||
<span class="drag-handle">⠿</span>
|
||||
<div class="sel-item-info">
|
||||
<div class="sel-item-title">${esc(r.title)}</div>
|
||||
<div class="sel-item-code">${code}</div>
|
||||
</div>
|
||||
<button class="remove-btn" onclick="removeFromGen('${code}')">✕</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function removeFromGen(code) {
|
||||
selected.delete(code);
|
||||
genOrder = genOrder.filter(c => c !== code);
|
||||
renderGenerator();
|
||||
}
|
||||
|
||||
function clearGenerator() { selected.clear(); genOrder = []; renderGenerator(); }
|
||||
|
||||
let dragIdx = null;
|
||||
function dragStart(e, idx) { dragIdx = idx; e.dataTransfer.effectAllowed = 'move'; }
|
||||
function dragOver(e, idx) { e.preventDefault(); e.currentTarget.classList.add('drag-over'); }
|
||||
function dragDrop(e, idx) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.classList.remove('drag-over');
|
||||
if (dragIdx === null || dragIdx === idx) return;
|
||||
const moved = genOrder.splice(dragIdx, 1)[0];
|
||||
genOrder.splice(idx, 0, moved);
|
||||
dragIdx = null;
|
||||
renderGenerator();
|
||||
}
|
||||
|
||||
function generateDoc(fmt) {
|
||||
if (genOrder.length === 0) { toast('Избери секции първо'); return; }
|
||||
const sections = genOrder.map(code => ALL.find(r => r.code === code)).filter(Boolean);
|
||||
|
||||
if (fmt === 'html') {
|
||||
const html = buildHtmlDoc(sections);
|
||||
const blob = new Blob([html], {type: 'text/html;charset=utf-8'});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const win = window.open(url, '_blank');
|
||||
if (!win) {
|
||||
toast('Браузърът блокира new tab — позволи pop-ups', true);
|
||||
download('help_document.html', html, 'text/html');
|
||||
} else {
|
||||
toast('HTML документът е отворен в нов tab');
|
||||
}
|
||||
return;
|
||||
}
|
||||
toast('Word / PDF — TODO', true);
|
||||
}
|
||||
|
||||
function buildHtmlDoc(sections) {
|
||||
// Картинките са relative URLs — за самостоятелен файл клиента ги embed-ва не може
|
||||
// лесно. За първа итерация: оставяме URLs както са (същият домейн ги сервира).
|
||||
const body = sections.map(s => `
|
||||
<section>
|
||||
<h2>${esc(s.title)}</h2>
|
||||
<p class="meta">${esc(s.code)} · ${esc(s.keywords||'')}</p>
|
||||
<div class="content">${s.text_html || esc(s.text||'').replace(/\n/g,'<br>')}</div>
|
||||
</section>
|
||||
`).join('<hr>');
|
||||
// Превръщаме относителни /images/... в абсолютни (HOST + path) за работа в нов tab
|
||||
const base = location.protocol + '//' + location.host;
|
||||
const bodyAbs = body.replace(/src="\/images\//g, `src="${base}/images/`);
|
||||
return `<!DOCTYPE html><html lang="bg"><head><meta charset="UTF-8">
|
||||
<title>Help документ</title>
|
||||
<style>
|
||||
body{font-family:Georgia,serif;max-width:860px;margin:40px auto;padding:0 20px;color:#222;line-height:1.7}
|
||||
h1{font-size:24px;border-bottom:2px solid #333;padding-bottom:10px}
|
||||
h2{font-size:18px;margin-top:0;color:#1a1a2e}
|
||||
.meta{font-size:11px;color:#888;font-family:monospace;margin-bottom:12px}
|
||||
.content{font-size:14px}
|
||||
hr{border:none;border-top:1px solid #e0e0e0;margin:32px 0}
|
||||
section{margin-bottom:8px}
|
||||
img{max-width:100%;border:1px solid #eee;border-radius:4px;margin:8px 0}
|
||||
</style></head><body>
|
||||
<h1>Help документ</h1>
|
||||
<p style="font-size:12px;color:#888">Генериран: ${new Date().toLocaleString('bg-BG')}</p>
|
||||
<hr>
|
||||
${bodyAbs}
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
// ── UTILS ─────────────────────────────
|
||||
function esc(s) {
|
||||
return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
function shortPath(p) {
|
||||
if (!p) return '';
|
||||
const parts = p.replace(/\\/g,'/').split('/');
|
||||
return parts.slice(-2).join('/');
|
||||
}
|
||||
function toast(msg, isError) {
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.classList.toggle('error', !!isError);
|
||||
el.classList.add('show');
|
||||
setTimeout(() => el.classList.remove('show'), 3000);
|
||||
}
|
||||
function download(filename, content, mime) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(new Blob([content], {type: mime}));
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user