This commit is contained in:
468
cmd/server/web/app.js
Normal file
468
cmd/server/web/app.js
Normal file
@@ -0,0 +1,468 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const $ = (sel, root = document) => root.querySelector(sel);
|
||||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||
|
||||
const state = {
|
||||
page: 1,
|
||||
pageSize: 60,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
items: [],
|
||||
selected: new Set(),
|
||||
currentKey: null,
|
||||
currentDoc: null,
|
||||
currentMeta: null,
|
||||
dirty: false,
|
||||
activeTab: 'form',
|
||||
lastBulkPreviewSignature: '',
|
||||
};
|
||||
|
||||
const els = {
|
||||
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), healthPill: $('#healthPill'), reloadBtn: $('#reloadBtn'), bulkBtn: $('#bulkBtn'),
|
||||
searchInput: $('#searchInput'), autoReplyFilter: $('#autoReplyFilter'), languageFilter: $('#languageFilter'),
|
||||
sourceFilter: $('#sourceFilter'), styleFilter: $('#styleFilter'), selectPage: $('#selectPage'),
|
||||
selectionCount: $('#selectionCount'), resultList: $('#resultList'), prevPage: $('#prevPage'), nextPage: $('#nextPage'),
|
||||
pageLabel: $('#pageLabel'), totalLabel: $('#totalLabel'), emptyState: $('#emptyState'), editor: $('#editor'),
|
||||
filePath: $('#filePath'), dirtyBadge: $('#dirtyBadge'), saveBtn: $('#saveBtn'), formatJsonBtn: $('#formatJsonBtn'),
|
||||
formTab: $('#formTab'), rawTab: $('#rawTab'), rawEditor: $('#rawEditor'), rawError: $('#rawError'),
|
||||
bulkDialog: $('#bulkDialog'), bulkTargetText: $('#bulkTargetText'), bulkAllMatching: $('#bulkAllMatching'),
|
||||
allMatchingHint: $('#allMatchingHint'), bulkPreview: $('#bulkPreview'), previewBulkBtn: $('#previewBulkBtn'),
|
||||
applyBulkBtn: $('#applyBulkBtn'), toastHost: $('#toastHost')
|
||||
};
|
||||
|
||||
let searchTimer;
|
||||
|
||||
async function api(url, options = {}) {
|
||||
const res = await fetch(url, options);
|
||||
const contentType = res.headers.get('content-type') || '';
|
||||
const body = contentType.includes('application/json') ? await res.json() : await res.text();
|
||||
if (!res.ok) {
|
||||
const msg = typeof body === 'object' && body?.error ? body.error : `${res.status} ${res.statusText}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
function currentQuery(page = state.page) {
|
||||
return {
|
||||
q: els.searchInput.value.trim(),
|
||||
auto_reply: els.autoReplyFilter.value,
|
||||
language: els.languageFilter.value.trim(),
|
||||
communication_style: els.styleFilter.value.trim(),
|
||||
source: els.sourceFilter.value.trim(),
|
||||
page,
|
||||
page_size: state.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
function queryString(q) {
|
||||
const p = new URLSearchParams();
|
||||
Object.entries(q).forEach(([k, v]) => {
|
||||
if (v !== '' && v !== null && v !== undefined && !(k === 'auto_reply' && v === 'any')) p.set(k, v);
|
||||
});
|
||||
return p.toString();
|
||||
}
|
||||
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const [h, config] = await Promise.all([api('/api/health'), api('/api/config')]);
|
||||
if (config.title) {
|
||||
els.brandTitle.textContent = config.title;
|
||||
document.title = config.title;
|
||||
}
|
||||
if (config.subtitle) els.brandSubtitle.textContent = config.subtitle;
|
||||
els.healthPill.textContent = `${h.count.toLocaleString('de-DE')} Dateien`;
|
||||
els.healthPill.className = 'pill ok';
|
||||
els.healthPill.title = `Daten: ${h.data_dir}\nBackups: ${h.backup_dir}`;
|
||||
} catch (err) {
|
||||
els.healthPill.textContent = 'Offline';
|
||||
els.healthPill.className = 'pill';
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadList(resetPage = false) {
|
||||
if (resetPage) state.page = 1;
|
||||
els.resultList.innerHTML = '<div class="muted-text" style="padding:16px">Lade …</div>';
|
||||
try {
|
||||
const data = await api(`/api/items?${queryString(currentQuery())}`);
|
||||
state.page = data.page || 1;
|
||||
state.total = data.total;
|
||||
state.totalPages = data.total_pages;
|
||||
state.items = data.items || [];
|
||||
renderList();
|
||||
} catch (err) {
|
||||
els.resultList.innerHTML = `<div class="inline-error" style="margin:12px">${escapeHTML(err.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
els.resultList.innerHTML = '';
|
||||
if (state.items.length === 0) {
|
||||
els.resultList.innerHTML = '<div class="muted-text" style="padding:20px;text-align:center">Keine Treffer.</div>';
|
||||
}
|
||||
for (const item of state.items) {
|
||||
const row = document.createElement('div');
|
||||
row.className = `result-item${item.key === state.currentKey ? ' active' : ''}`;
|
||||
row.dataset.key = item.key;
|
||||
const checked = state.selected.has(item.key) ? 'checked' : '';
|
||||
const auto = item.auto_reply === true;
|
||||
row.innerHTML = `
|
||||
<input class="result-check" type="checkbox" ${checked} aria-label="Auswählen">
|
||||
<div>
|
||||
<div class="result-id">${escapeHTML(item.id || item.rel_path)}</div>
|
||||
<div class="result-title">${escapeHTML(item.title || '(ohne Titel)')}</div>
|
||||
<div class="result-meta">
|
||||
<span><i class="bool-dot ${auto ? 'true' : ''}"></i>auto ${String(item.auto_reply ?? '–')}</span>
|
||||
<span>score ${item.min_score ?? '–'}</span>
|
||||
<span>${escapeHTML(item.language || '–')}</span>
|
||||
<span>${escapeHTML(item.source || '')}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
const cb = $('.result-check', row);
|
||||
cb.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
toggleSelection(item.key, cb.checked);
|
||||
});
|
||||
row.addEventListener('click', () => openItem(item.key));
|
||||
els.resultList.appendChild(row);
|
||||
}
|
||||
els.pageLabel.textContent = state.totalPages ? `Seite ${state.page} / ${state.totalPages}` : 'Seite 0 / 0';
|
||||
els.totalLabel.textContent = `${state.total.toLocaleString('de-DE')} Treffer`;
|
||||
els.prevPage.disabled = state.page <= 1;
|
||||
els.nextPage.disabled = state.totalPages === 0 || state.page >= state.totalPages;
|
||||
els.selectPage.checked = state.items.length > 0 && state.items.every(i => state.selected.has(i.key));
|
||||
els.selectPage.indeterminate = !els.selectPage.checked && state.items.some(i => state.selected.has(i.key));
|
||||
updateSelectionUI();
|
||||
}
|
||||
|
||||
function toggleSelection(key, checked) {
|
||||
if (checked) state.selected.add(key); else state.selected.delete(key);
|
||||
renderSelectionOnly();
|
||||
}
|
||||
|
||||
function renderSelectionOnly() {
|
||||
els.selectionCount.textContent = `${state.selected.size.toLocaleString('de-DE')} ausgewählt`;
|
||||
els.bulkBtn.disabled = state.selected.size === 0 && state.total === 0;
|
||||
els.selectPage.checked = state.items.length > 0 && state.items.every(i => state.selected.has(i.key));
|
||||
els.selectPage.indeterminate = !els.selectPage.checked && state.items.some(i => state.selected.has(i.key));
|
||||
}
|
||||
|
||||
function updateSelectionUI() { renderSelectionOnly(); }
|
||||
|
||||
async function openItem(key) {
|
||||
if (key === state.currentKey) return;
|
||||
if (state.dirty && !confirm('Es gibt ungespeicherte Änderungen. Wirklich einen anderen Eintrag öffnen?')) return;
|
||||
try {
|
||||
const data = await api(`/api/items/${encodeURIComponent(key)}`);
|
||||
state.currentKey = key;
|
||||
state.currentDoc = data.document;
|
||||
state.currentMeta = data.meta;
|
||||
state.dirty = false;
|
||||
showEditor();
|
||||
fillForm();
|
||||
setTab('form');
|
||||
renderList();
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function showEditor() {
|
||||
els.emptyState.classList.add('hidden');
|
||||
els.editor.classList.remove('hidden');
|
||||
els.filePath.textContent = state.currentMeta?.rel_path || '–';
|
||||
setDirty(false);
|
||||
}
|
||||
|
||||
function fillForm() {
|
||||
$$('[data-field]').forEach(input => {
|
||||
const name = input.dataset.field;
|
||||
const val = state.currentDoc?.[name];
|
||||
if (input.type === 'checkbox') input.checked = Boolean(val);
|
||||
else input.value = val ?? '';
|
||||
});
|
||||
$$('[data-list-field]').forEach(input => {
|
||||
const val = state.currentDoc?.[input.dataset.listField];
|
||||
input.value = Array.isArray(val) ? val.join('\n') : '';
|
||||
});
|
||||
els.rawEditor.value = JSON.stringify(state.currentDoc, null, 2);
|
||||
clearRawError();
|
||||
}
|
||||
|
||||
function syncFormToDoc() {
|
||||
if (!state.currentDoc) return;
|
||||
$$('[data-field]').forEach(input => {
|
||||
const name = input.dataset.field;
|
||||
if (input.type === 'checkbox') state.currentDoc[name] = input.checked;
|
||||
else if (input.type === 'number') {
|
||||
if (input.value === '') delete state.currentDoc[name];
|
||||
else state.currentDoc[name] = Number(input.value);
|
||||
} else state.currentDoc[name] = input.value;
|
||||
});
|
||||
$$('[data-list-field]').forEach(input => {
|
||||
state.currentDoc[input.dataset.listField] = lines(input.value);
|
||||
});
|
||||
}
|
||||
|
||||
function syncRawToDoc() {
|
||||
try {
|
||||
const parsed = JSON.parse(els.rawEditor.value);
|
||||
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') throw new Error('Die JSON-Wurzel muss ein Objekt sein.');
|
||||
state.currentDoc = parsed;
|
||||
clearRawError();
|
||||
return true;
|
||||
} catch (err) {
|
||||
els.rawError.textContent = `JSON-Fehler: ${err.message}`;
|
||||
els.rawError.classList.remove('hidden');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearRawError() {
|
||||
els.rawError.textContent = '';
|
||||
els.rawError.classList.add('hidden');
|
||||
}
|
||||
|
||||
function setDirty(v = true) {
|
||||
state.dirty = v;
|
||||
els.dirtyBadge.classList.toggle('hidden', !v);
|
||||
}
|
||||
|
||||
function setTab(tab) {
|
||||
if (tab === state.activeTab) return;
|
||||
if (state.activeTab === 'raw' && tab === 'form') {
|
||||
if (!syncRawToDoc()) return;
|
||||
fillForm();
|
||||
} else if (state.activeTab === 'form' && tab === 'raw') {
|
||||
syncFormToDoc();
|
||||
els.rawEditor.value = JSON.stringify(state.currentDoc, null, 2);
|
||||
}
|
||||
state.activeTab = tab;
|
||||
$$('.tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
||||
els.formTab.classList.toggle('active', tab === 'form');
|
||||
els.rawTab.classList.toggle('active', tab === 'raw');
|
||||
els.formatJsonBtn.classList.toggle('hidden', tab !== 'raw');
|
||||
}
|
||||
|
||||
async function saveCurrent() {
|
||||
if (!state.currentKey || !state.currentDoc) return;
|
||||
if (state.activeTab === 'raw') {
|
||||
if (!syncRawToDoc()) return;
|
||||
} else syncFormToDoc();
|
||||
|
||||
els.saveBtn.disabled = true;
|
||||
try {
|
||||
const result = await api(`/api/items/${encodeURIComponent(state.currentKey)}`, {
|
||||
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(state.currentDoc)
|
||||
});
|
||||
state.currentMeta = result.meta;
|
||||
setDirty(false);
|
||||
toast(`Gespeichert. Backup: ${shortPath(result.backup)}`, 'success');
|
||||
await loadList(false);
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
els.saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openBulk() {
|
||||
if (state.selected.size === 0 && state.total === 0) return;
|
||||
resetBulkPreview();
|
||||
els.bulkAllMatching.checked = state.selected.size === 0;
|
||||
updateBulkTargetText();
|
||||
els.bulkDialog.showModal();
|
||||
}
|
||||
|
||||
function updateBulkTargetText() {
|
||||
const all = els.bulkAllMatching.checked;
|
||||
els.bulkTargetText.textContent = all
|
||||
? `${state.total.toLocaleString('de-DE')} aktuelle Treffer als Ziel`
|
||||
: `${state.selected.size.toLocaleString('de-DE')} explizit ausgewählte Dateien als Ziel`;
|
||||
els.allMatchingHint.textContent = `Aktueller Filter: ${state.total.toLocaleString('de-DE')} Treffer`;
|
||||
}
|
||||
|
||||
function buildPatch() {
|
||||
const patch = {};
|
||||
const enabled = id => $(`[data-enable="${id}"]`)?.checked;
|
||||
if (enabled('bulkAutoReply')) patch.set_auto_reply = $('#bulkAutoReply').value === 'true';
|
||||
if (enabled('bulkMinScore')) patch.set_min_score = Number($('#bulkMinScore').value);
|
||||
if (enabled('bulkLanguage')) patch.set_language = $('#bulkLanguage').value;
|
||||
if (enabled('bulkStyle')) patch.set_communication_style = $('#bulkStyle').value;
|
||||
if (enabled('bulkSource')) patch.set_source = $('#bulkSource').value;
|
||||
if (enabled('bulkSourceUri')) patch.set_source_uri = $('#bulkSourceUri').value;
|
||||
|
||||
const addK = lines($('#addKeywords').value), rmK = lines($('#removeKeywords').value);
|
||||
const addC = lines($('#addCategories').value), rmC = lines($('#removeCategories').value);
|
||||
if (addK.length) patch.add_keywords = addK;
|
||||
if (rmK.length) patch.remove_keywords = rmK;
|
||||
if (addC.length) patch.add_categories = addC;
|
||||
if (rmC.length) patch.remove_categories = rmC;
|
||||
|
||||
const find = $('#replaceFind').value;
|
||||
if (find) {
|
||||
patch.find_replace = {
|
||||
fields: $$('input[name="replaceField"]:checked').map(x => x.value),
|
||||
find,
|
||||
replace: $('#replaceWith').value,
|
||||
regex: $('#replaceRegex').checked,
|
||||
case_sensitive: $('#replaceCase').checked,
|
||||
};
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
function buildBulkRequest(dryRun) {
|
||||
const q = currentQuery(1);
|
||||
q.page = 0; q.page_size = 0;
|
||||
return {
|
||||
keys: Array.from(state.selected),
|
||||
all_matching: els.bulkAllMatching.checked,
|
||||
query: q,
|
||||
patch: buildPatch(),
|
||||
dry_run: dryRun,
|
||||
};
|
||||
}
|
||||
|
||||
async function previewBulk() {
|
||||
const req = buildBulkRequest(true);
|
||||
if (!Object.keys(req.patch).length) {
|
||||
toast('Bitte mindestens eine Änderung festlegen.', 'error');
|
||||
return;
|
||||
}
|
||||
els.previewBulkBtn.disabled = true;
|
||||
try {
|
||||
const result = await api('/api/bulk', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(req)
|
||||
});
|
||||
state.lastBulkPreviewSignature = signatureFor(req);
|
||||
renderBulkPreview(result);
|
||||
els.applyBulkBtn.disabled = result.changed === 0;
|
||||
} catch (err) {
|
||||
els.bulkPreview.textContent = err.message;
|
||||
els.bulkPreview.className = 'preview-box warn';
|
||||
els.applyBulkBtn.disabled = true;
|
||||
} finally {
|
||||
els.previewBulkBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderBulkPreview(result) {
|
||||
els.bulkPreview.className = `preview-box ${result.changed > 0 ? 'ok' : 'warn'}`;
|
||||
els.bulkPreview.innerHTML = `
|
||||
<strong>Vorschau:</strong> ${result.changed.toLocaleString('de-DE')} von ${result.targeted.toLocaleString('de-DE')} Dateien würden geändert,
|
||||
${result.skipped.toLocaleString('de-DE')} bleiben unverändert.
|
||||
${result.sample?.length ? `<div class="preview-samples">${result.sample.map(x => `<code>${escapeHTML(x.id || x.rel_path)} · ${escapeHTML(x.title || '')}</code>`).join('')}</div>` : ''}`;
|
||||
}
|
||||
|
||||
async function applyBulk() {
|
||||
const req = buildBulkRequest(false);
|
||||
const sig = signatureFor({...req, dry_run: true});
|
||||
if (sig !== state.lastBulkPreviewSignature) {
|
||||
toast('Die Massenänderung wurde seit der Vorschau verändert. Bitte erneut Vorschau ausführen.', 'error');
|
||||
els.applyBulkBtn.disabled = true;
|
||||
return;
|
||||
}
|
||||
if (!confirm('Massenänderung jetzt wirklich auf die Zieldateien anwenden?')) return;
|
||||
els.applyBulkBtn.disabled = true;
|
||||
try {
|
||||
const result = await api('/api/bulk', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(req)
|
||||
});
|
||||
toast(`${result.changed.toLocaleString('de-DE')} Dateien geändert. Backup: ${shortPath(result.backup)}`, 'success');
|
||||
els.bulkDialog.close();
|
||||
state.selected.clear();
|
||||
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false;
|
||||
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
|
||||
await Promise.all([loadList(true), loadHealth()]);
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function resetBulkPreview() {
|
||||
state.lastBulkPreviewSignature = '';
|
||||
els.bulkPreview.className = 'preview-box hidden';
|
||||
els.bulkPreview.textContent = '';
|
||||
els.applyBulkBtn.disabled = true;
|
||||
}
|
||||
|
||||
function signatureFor(obj) { return JSON.stringify(obj); }
|
||||
function lines(s) { return s.split(/\r?\n/).map(x => x.trim()).filter(Boolean); }
|
||||
function shortPath(p) { if (!p) return '–'; const parts = p.split('/'); return parts.slice(-2).join('/'); }
|
||||
function escapeHTML(s) { return String(s ?? '').replace(/[&<>'"]/g, c => ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); }
|
||||
|
||||
function toast(message, type = '') {
|
||||
const el = document.createElement('div');
|
||||
el.className = `toast ${type}`;
|
||||
el.textContent = message;
|
||||
els.toastHost.appendChild(el);
|
||||
setTimeout(() => el.remove(), 5000);
|
||||
}
|
||||
|
||||
function debounceReload() {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => loadList(true), 240);
|
||||
}
|
||||
|
||||
// Filters and navigation.
|
||||
[els.searchInput, els.languageFilter, els.sourceFilter, els.styleFilter].forEach(el => el.addEventListener('input', debounceReload));
|
||||
els.autoReplyFilter.addEventListener('change', () => loadList(true));
|
||||
els.prevPage.addEventListener('click', () => { if (state.page > 1) { state.page--; loadList(); } });
|
||||
els.nextPage.addEventListener('click', () => { if (state.page < state.totalPages) { state.page++; loadList(); } });
|
||||
els.selectPage.addEventListener('change', () => {
|
||||
for (const item of state.items) {
|
||||
if (els.selectPage.checked) state.selected.add(item.key); else state.selected.delete(item.key);
|
||||
}
|
||||
renderList();
|
||||
});
|
||||
|
||||
// Editor.
|
||||
$$('[data-field], [data-list-field]').forEach(el => el.addEventListener('input', () => setDirty(true)));
|
||||
els.rawEditor.addEventListener('input', () => { setDirty(true); clearRawError(); });
|
||||
$$('.tab').forEach(btn => btn.addEventListener('click', () => setTab(btn.dataset.tab)));
|
||||
els.saveBtn.addEventListener('click', saveCurrent);
|
||||
els.formatJsonBtn.addEventListener('click', () => {
|
||||
if (syncRawToDoc()) {
|
||||
els.rawEditor.value = JSON.stringify(state.currentDoc, null, 2);
|
||||
setDirty(true);
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk modal.
|
||||
els.bulkBtn.addEventListener('click', openBulk);
|
||||
els.bulkAllMatching.addEventListener('change', () => { updateBulkTargetText(); resetBulkPreview(); });
|
||||
$$('[data-enable]').forEach(toggle => toggle.addEventListener('change', () => {
|
||||
const target = document.getElementById(toggle.dataset.enable);
|
||||
if (target) target.disabled = !toggle.checked;
|
||||
resetBulkPreview();
|
||||
}));
|
||||
$$('#bulkDialog input, #bulkDialog textarea, #bulkDialog select').forEach(el => {
|
||||
if (el !== els.bulkAllMatching && !el.hasAttribute('data-enable')) el.addEventListener('input', resetBulkPreview);
|
||||
});
|
||||
els.previewBulkBtn.addEventListener('click', previewBulk);
|
||||
els.applyBulkBtn.addEventListener('click', applyBulk);
|
||||
|
||||
els.reloadBtn.addEventListener('click', async () => {
|
||||
if (state.dirty && !confirm('Ungespeicherte Änderungen verwerfen und Dateien neu einlesen?')) return;
|
||||
try {
|
||||
const r = await api('/api/reload', {method: 'POST', headers: {'Content-Type':'application/json'}, body:'{}'});
|
||||
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false; state.selected.clear();
|
||||
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
|
||||
toast(`${r.count.toLocaleString('de-DE')} Dateien neu eingelesen.`, 'success');
|
||||
await Promise.all([loadList(true), loadHealth()]);
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveCurrent(); }
|
||||
if (e.key === '/' && !['INPUT','TEXTAREA','SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); els.searchInput.focus(); }
|
||||
});
|
||||
window.addEventListener('beforeunload', e => { if (state.dirty) { e.preventDefault(); e.returnValue = ''; } });
|
||||
|
||||
loadHealth();
|
||||
loadList(true);
|
||||
})();
|
||||
251
cmd/server/web/index.html
Normal file
251
cmd/server/web/index.html
Normal file
@@ -0,0 +1,251 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>KB Mass Editor</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-shell">
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">KB</div>
|
||||
<div>
|
||||
<div id="brandTitle" class="brand-title">Knowledge Base Editor</div>
|
||||
<div id="brandSubtitle" class="brand-subtitle">JSON · Massenbearbeitung · Docker</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<span id="healthPill" class="pill muted">Verbinde …</span>
|
||||
<button id="reloadBtn" class="btn ghost" title="Dateien neu einlesen">↻ Neu einlesen</button>
|
||||
<button id="bulkBtn" class="btn primary" disabled>✦ Massenbearbeitung</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<aside class="sidebar">
|
||||
<section class="filters">
|
||||
<label class="search-wrap">
|
||||
<span>⌕</span>
|
||||
<input id="searchInput" type="search" placeholder="Code, Titel, Text, Keyword …" autocomplete="off">
|
||||
<kbd>/</kbd>
|
||||
</label>
|
||||
<div class="filter-row">
|
||||
<select id="autoReplyFilter" aria-label="Auto Reply Filter">
|
||||
<option value="any">auto_reply: alle</option>
|
||||
<option value="true">auto_reply: true</option>
|
||||
<option value="false">auto_reply: false</option>
|
||||
</select>
|
||||
<input id="languageFilter" placeholder="Sprache, z. B. de-DE">
|
||||
</div>
|
||||
<div class="filter-row">
|
||||
<input id="sourceFilter" placeholder="Quelle enthält …">
|
||||
<input id="styleFilter" placeholder="Stil, z. B. formal">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="list-tools">
|
||||
<label class="check-label"><input id="selectPage" type="checkbox"> Seite auswählen</label>
|
||||
<span id="selectionCount" class="muted-text">0 ausgewählt</span>
|
||||
</section>
|
||||
|
||||
<div id="resultList" class="result-list" aria-live="polite"></div>
|
||||
|
||||
<footer class="pager">
|
||||
<button id="prevPage" class="icon-btn" aria-label="Vorherige Seite">‹</button>
|
||||
<div>
|
||||
<strong id="pageLabel">Seite 1</strong>
|
||||
<span id="totalLabel">0 Treffer</span>
|
||||
</div>
|
||||
<button id="nextPage" class="icon-btn" aria-label="Nächste Seite">›</button>
|
||||
</footer>
|
||||
</aside>
|
||||
|
||||
<main class="main-pane">
|
||||
<div id="emptyState" class="empty-state">
|
||||
<div class="empty-icon">{ }</div>
|
||||
<h1>JSON-Wissensbasis bearbeiten</h1>
|
||||
<p>Wähle links einen Eintrag aus oder markiere mehrere Dateien für eine Massenänderung.</p>
|
||||
<div class="empty-cards">
|
||||
<div><strong>Sicher</strong><span>Automatische Backups vor jedem Schreibvorgang</span></div>
|
||||
<div><strong>Schnell</strong><span>Indexierte Suche auch bei zehntausenden JSON-Dateien</span></div>
|
||||
<div><strong>Flexibel</strong><span>Formularansicht und vollständiger Raw-JSON-Editor</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="editor" class="editor hidden">
|
||||
<div class="editor-head">
|
||||
<div class="breadcrumb">
|
||||
<span id="filePath">–</span>
|
||||
<span id="dirtyBadge" class="badge warn hidden">Ungespeichert</span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button id="formatJsonBtn" class="btn ghost hidden">JSON formatieren</button>
|
||||
<button id="saveBtn" class="btn success">Speichern <span class="shortcut">Ctrl S</span></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs" role="tablist">
|
||||
<button class="tab active" data-tab="form">Formular</button>
|
||||
<button class="tab" data-tab="raw">Raw JSON</button>
|
||||
</div>
|
||||
|
||||
<div id="formTab" class="tab-panel active">
|
||||
<div class="form-grid">
|
||||
<label class="field span-2">
|
||||
<span>ID</span>
|
||||
<input data-field="id" placeholder="KB-…">
|
||||
</label>
|
||||
<label class="field span-10">
|
||||
<span>Titel</span>
|
||||
<input data-field="title" placeholder="Titel des KB-Artikels">
|
||||
</label>
|
||||
|
||||
<label class="field span-12">
|
||||
<span>Erkennungstext / Problem</span>
|
||||
<textarea data-field="text" rows="7" placeholder="Beschreibung, Fehlerkontext, Erkennung …"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="field span-12">
|
||||
<span>Antwort / Lösung</span>
|
||||
<textarea data-field="answer" rows="10" placeholder="Lösungsschritte …"></textarea>
|
||||
</label>
|
||||
|
||||
<label class="field span-3 switch-field">
|
||||
<span>Automatische Antwort</span>
|
||||
<span class="switch-line"><input data-field="auto_reply" type="checkbox"><span>auto_reply</span></span>
|
||||
</label>
|
||||
<label class="field span-3">
|
||||
<span>Min. Score</span>
|
||||
<input data-field="min_score" type="number" min="0" max="1" step="0.01">
|
||||
</label>
|
||||
<label class="field span-3">
|
||||
<span>Sprache</span>
|
||||
<input data-field="language" placeholder="de-DE">
|
||||
</label>
|
||||
<label class="field span-3">
|
||||
<span>Kommunikationsstil</span>
|
||||
<input data-field="communication_style" placeholder="formal">
|
||||
</label>
|
||||
|
||||
<label class="field span-4">
|
||||
<span>Quelle</span>
|
||||
<input data-field="source" placeholder="Microsoft Learn">
|
||||
</label>
|
||||
<label class="field span-8">
|
||||
<span>Quell-URL</span>
|
||||
<input data-field="source_uri" placeholder="https://…">
|
||||
</label>
|
||||
|
||||
<label class="field span-6">
|
||||
<span>Keywords <small>eine Zeile pro Wert</small></span>
|
||||
<textarea data-list-field="keywords" rows="7" placeholder="Windows 0x80070005 Zugriff verweigert"></textarea>
|
||||
</label>
|
||||
<label class="field span-6">
|
||||
<span>Kategorien <small>eine Zeile pro Wert</small></span>
|
||||
<textarea data-list-field="categories" rows="7" placeholder="Windows Aktivierung"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="rawTab" class="tab-panel">
|
||||
<div id="rawError" class="inline-error hidden"></div>
|
||||
<textarea id="rawEditor" class="raw-editor" spellcheck="false"></textarea>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<dialog id="bulkDialog" class="modal">
|
||||
<form method="dialog" class="modal-card" id="bulkForm">
|
||||
<header class="modal-head">
|
||||
<div>
|
||||
<h2>Massenbearbeitung</h2>
|
||||
<p id="bulkTargetText">–</p>
|
||||
</div>
|
||||
<button value="cancel" class="icon-btn" aria-label="Schließen">×</button>
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<label class="target-choice">
|
||||
<input id="bulkAllMatching" type="checkbox">
|
||||
<span><strong>Alle aktuellen Treffer bearbeiten</strong><small id="allMatchingHint"></small></span>
|
||||
</label>
|
||||
|
||||
<div class="bulk-grid">
|
||||
<div class="bulk-section">
|
||||
<h3>Felder setzen</h3>
|
||||
<label class="bulk-control">
|
||||
<input type="checkbox" data-enable="bulkAutoReply">
|
||||
<span>auto_reply</span>
|
||||
<select id="bulkAutoReply" disabled><option value="true">true</option><option value="false">false</option></select>
|
||||
</label>
|
||||
<label class="bulk-control">
|
||||
<input type="checkbox" data-enable="bulkMinScore">
|
||||
<span>min_score</span>
|
||||
<input id="bulkMinScore" type="number" min="0" max="1" step="0.01" value="0.78" disabled>
|
||||
</label>
|
||||
<label class="bulk-control">
|
||||
<input type="checkbox" data-enable="bulkLanguage">
|
||||
<span>language</span>
|
||||
<input id="bulkLanguage" value="de-DE" disabled>
|
||||
</label>
|
||||
<label class="bulk-control">
|
||||
<input type="checkbox" data-enable="bulkStyle">
|
||||
<span>communication_style</span>
|
||||
<input id="bulkStyle" value="formal" disabled>
|
||||
</label>
|
||||
<label class="bulk-control">
|
||||
<input type="checkbox" data-enable="bulkSource">
|
||||
<span>source</span>
|
||||
<input id="bulkSource" placeholder="Microsoft Learn" disabled>
|
||||
</label>
|
||||
<label class="bulk-control">
|
||||
<input type="checkbox" data-enable="bulkSourceUri">
|
||||
<span>source_uri</span>
|
||||
<input id="bulkSourceUri" placeholder="https://…" disabled>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="bulk-section">
|
||||
<h3>Listen ändern</h3>
|
||||
<label class="field compact"><span>Keywords hinzufügen</span><textarea id="addKeywords" rows="3" placeholder="ein Wert pro Zeile"></textarea></label>
|
||||
<label class="field compact"><span>Keywords entfernen</span><textarea id="removeKeywords" rows="3"></textarea></label>
|
||||
<label class="field compact"><span>Kategorien hinzufügen</span><textarea id="addCategories" rows="3"></textarea></label>
|
||||
<label class="field compact"><span>Kategorien entfernen</span><textarea id="removeCategories" rows="3"></textarea></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bulk-section replace-section">
|
||||
<h3>Suchen & Ersetzen <small>optional</small></h3>
|
||||
<div class="replace-grid">
|
||||
<input id="replaceFind" placeholder="Suchen nach …">
|
||||
<input id="replaceWith" placeholder="Ersetzen durch …">
|
||||
</div>
|
||||
<div class="replace-options">
|
||||
<label><input type="checkbox" name="replaceField" value="title" checked> Titel</label>
|
||||
<label><input type="checkbox" name="replaceField" value="text" checked> Text</label>
|
||||
<label><input type="checkbox" name="replaceField" value="answer" checked> Antwort</label>
|
||||
<label><input id="replaceRegex" type="checkbox"> Regex</label>
|
||||
<label><input id="replaceCase" type="checkbox"> Groß-/Kleinschreibung</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="bulkPreview" class="preview-box muted hidden"></div>
|
||||
</div>
|
||||
|
||||
<footer class="modal-foot">
|
||||
<span class="muted-text">Vor dem Anwenden wird automatisch ein Backup erstellt.</span>
|
||||
<div>
|
||||
<button value="cancel" class="btn ghost">Abbrechen</button>
|
||||
<button id="previewBulkBtn" type="button" class="btn">Vorschau</button>
|
||||
<button id="applyBulkBtn" type="button" class="btn danger" disabled>Änderungen anwenden</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="toastHost" class="toast-host" aria-live="polite"></div>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
199
cmd/server/web/style.css
Normal file
199
cmd/server/web/style.css
Normal file
@@ -0,0 +1,199 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--bg: #0b1020;
|
||||
--panel: #11182b;
|
||||
--panel-2: #151f35;
|
||||
--panel-3: #1a2742;
|
||||
--text: #eef3ff;
|
||||
--muted: #93a0bb;
|
||||
--border: #273654;
|
||||
--accent: #7aa2ff;
|
||||
--accent-2: #9d8cff;
|
||||
--success: #47d7a7;
|
||||
--danger: #ff6f7f;
|
||||
--warning: #f2be61;
|
||||
--shadow: 0 20px 60px rgba(0,0,0,.28);
|
||||
--radius: 14px;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; min-height: 100%; background: var(--bg); color: var(--text); }
|
||||
body { overflow: hidden; }
|
||||
button, input, textarea, select { font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
.hidden { display: none !important; }
|
||||
.muted-text { color: var(--muted); font-size: 12px; }
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 410px minmax(0, 1fr);
|
||||
grid-template-rows: 70px calc(100vh - 70px);
|
||||
min-height: 100vh;
|
||||
}
|
||||
.topbar {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: rgba(11,16,32,.94);
|
||||
backdrop-filter: blur(14px);
|
||||
z-index: 5;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.brand-mark {
|
||||
width: 38px; height: 38px; display: grid; place-items: center;
|
||||
border-radius: 11px; font-weight: 800; letter-spacing: -.04em;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #07101f;
|
||||
box-shadow: 0 8px 25px rgba(122,162,255,.28);
|
||||
}
|
||||
.brand-title { font-size: 15px; font-weight: 750; }
|
||||
.brand-subtitle { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
.top-actions { display: flex; align-items: center; gap: 9px; }
|
||||
|
||||
.btn, .icon-btn {
|
||||
border: 1px solid var(--border); color: var(--text); background: var(--panel-2);
|
||||
border-radius: 9px; padding: 9px 13px; transition: .16s ease;
|
||||
}
|
||||
.btn:hover, .icon-btn:hover { border-color: #3a4e76; transform: translateY(-1px); }
|
||||
.btn:disabled, .icon-btn:disabled { opacity: .42; cursor: not-allowed; transform: none; }
|
||||
.btn.primary { border-color: transparent; background: linear-gradient(135deg, #5c8eff, #8c72f2); }
|
||||
.btn.success { border-color: rgba(71,215,167,.35); background: rgba(71,215,167,.13); color: #8cf0cd; }
|
||||
.btn.danger { border-color: rgba(255,111,127,.35); background: rgba(255,111,127,.13); color: #ff9ca7; }
|
||||
.btn.ghost { background: transparent; }
|
||||
.shortcut { opacity: .5; font-size: 10px; margin-left: 5px; }
|
||||
.icon-btn { width: 36px; height: 36px; padding: 0; font-size: 22px; display: grid; place-items: center; }
|
||||
.pill, .badge {
|
||||
display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 5px 9px;
|
||||
font-size: 11px; border: 1px solid var(--border); background: rgba(255,255,255,.03);
|
||||
}
|
||||
.pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: currentColor; }
|
||||
.pill.ok { color: var(--success); }
|
||||
.pill.muted { color: var(--muted); }
|
||||
.badge.warn { color: var(--warning); border-color: rgba(242,190,97,.25); }
|
||||
|
||||
.sidebar {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
border-right: 1px solid var(--border);
|
||||
background: #0e1526;
|
||||
}
|
||||
.filters { padding: 14px; border-bottom: 1px solid var(--border); }
|
||||
.search-wrap {
|
||||
display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 8px;
|
||||
background: var(--panel-2); border: 1px solid var(--border); border-radius: 11px; padding: 0 10px;
|
||||
}
|
||||
.search-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(122,162,255,.08); }
|
||||
.search-wrap input { border: 0; background: transparent; padding: 11px 0; outline: 0; color: var(--text); min-width: 0; }
|
||||
kbd { color: var(--muted); border: 1px solid var(--border); padding: 1px 5px; border-radius: 5px; font-size: 10px; }
|
||||
.filter-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
|
||||
input, textarea, select {
|
||||
width: 100%; color: var(--text); background: #0f1728; border: 1px solid var(--border); border-radius: 9px;
|
||||
padding: 9px 10px; outline: none;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(122,162,255,.07); }
|
||||
textarea { resize: vertical; line-height: 1.45; }
|
||||
.list-tools { display: flex; justify-content: space-between; align-items: center; padding: 9px 14px; border-bottom: 1px solid var(--border); }
|
||||
.check-label { display: flex; align-items: center; gap: 7px; font-size: 12px; color: #c8d2e8; }
|
||||
.check-label input, .target-choice input, .replace-options input, .bulk-control > input[type="checkbox"] { width: auto; accent-color: var(--accent); }
|
||||
.result-list { overflow: auto; min-height: 0; }
|
||||
.result-item {
|
||||
display: grid; grid-template-columns: 24px minmax(0, 1fr); gap: 9px;
|
||||
padding: 12px 13px; border-bottom: 1px solid rgba(39,54,84,.72); cursor: pointer; transition: background .12s;
|
||||
}
|
||||
.result-item:hover { background: rgba(122,162,255,.055); }
|
||||
.result-item.active { background: rgba(122,162,255,.11); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
.result-check { margin-top: 4px; width: auto; accent-color: var(--accent); }
|
||||
.result-title { font-size: 13px; line-height: 1.32; font-weight: 650; overflow-wrap: anywhere; }
|
||||
.result-id { font-size: 10px; color: #9bb4e8; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; margin-bottom: 4px; }
|
||||
.result-meta { display: flex; flex-wrap: wrap; gap: 5px 8px; margin-top: 7px; color: var(--muted); font-size: 10px; }
|
||||
.bool-dot { width: 7px; height: 7px; border-radius: 50%; display: inline-block; margin-right: 3px; background: var(--danger); }
|
||||
.bool-dot.true { background: var(--success); }
|
||||
.pager { display: grid; grid-template-columns: 36px 1fr 36px; align-items: center; gap: 10px; padding: 11px 13px; border-top: 1px solid var(--border); }
|
||||
.pager div { text-align: center; display: grid; gap: 2px; }
|
||||
.pager strong { font-size: 12px; }
|
||||
.pager span { font-size: 10px; color: var(--muted); }
|
||||
|
||||
.main-pane { grid-column: 2; grid-row: 2; overflow: auto; background: radial-gradient(circle at 70% 0%, rgba(103,85,190,.10), transparent 28%), var(--bg); }
|
||||
.empty-state { min-height: 100%; display: grid; place-content: center; justify-items: center; text-align: center; padding: 40px; }
|
||||
.empty-icon { font: 700 42px ui-monospace, monospace; color: var(--accent); opacity: .8; }
|
||||
.empty-state h1 { margin: 12px 0 6px; font-size: 25px; }
|
||||
.empty-state > p { color: var(--muted); max-width: 580px; }
|
||||
.empty-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; max-width: 760px; margin-top: 28px; }
|
||||
.empty-cards div { text-align: left; background: rgba(17,24,43,.72); border: 1px solid var(--border); border-radius: 12px; padding: 14px; }
|
||||
.empty-cards strong { display: block; font-size: 12px; margin-bottom: 5px; }
|
||||
.empty-cards span { color: var(--muted); font-size: 11px; line-height: 1.4; }
|
||||
.editor { min-height: 100%; }
|
||||
.editor-head { position: sticky; top: 0; z-index: 4; display: flex; justify-content: space-between; align-items: center; padding: 12px 22px; border-bottom: 1px solid var(--border); background: rgba(11,16,32,.92); backdrop-filter: blur(14px); }
|
||||
.breadcrumb { display: flex; align-items: center; gap: 9px; min-width: 0; }
|
||||
#filePath { font: 11px ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 55vw; }
|
||||
.editor-actions { display: flex; gap: 8px; }
|
||||
.tabs { display: flex; gap: 5px; padding: 14px 24px 0; }
|
||||
.tab { border: 0; color: var(--muted); background: transparent; padding: 9px 12px; border-bottom: 2px solid transparent; }
|
||||
.tab.active { color: var(--text); border-color: var(--accent); }
|
||||
.tab-panel { display: none; padding: 18px 24px 50px; }
|
||||
.tab-panel.active { display: block; }
|
||||
.form-grid { display: grid; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 14px; max-width: 1200px; margin: 0 auto; }
|
||||
.span-2 { grid-column: span 2; } .span-3 { grid-column: span 3; } .span-4 { grid-column: span 4; }
|
||||
.span-6 { grid-column: span 6; } .span-8 { grid-column: span 8; } .span-10 { grid-column: span 10; } .span-12 { grid-column: span 12; }
|
||||
.field { display: grid; gap: 6px; min-width: 0; }
|
||||
.field > span { font-size: 11px; color: #bac6dc; font-weight: 650; }
|
||||
.field small { color: var(--muted); font-weight: 400; margin-left: 6px; }
|
||||
.switch-field { align-content: end; }
|
||||
.switch-line { min-height: 39px; display: flex; align-items: center; gap: 8px; padding: 0 10px; border: 1px solid var(--border); border-radius: 9px; background: #0f1728; }
|
||||
.switch-line input { width: auto; accent-color: var(--accent); }
|
||||
.raw-editor { min-height: calc(100vh - 190px); resize: none; font: 12px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; tab-size: 2; }
|
||||
.inline-error { margin-bottom: 10px; padding: 10px 12px; border-radius: 9px; color: #ffb3bb; background: rgba(255,111,127,.09); border: 1px solid rgba(255,111,127,.25); font-size: 12px; }
|
||||
|
||||
.modal { width: min(1050px, calc(100vw - 40px)); max-height: calc(100vh - 40px); padding: 0; border: 1px solid var(--border); border-radius: 16px; color: var(--text); background: #0e1628; box-shadow: var(--shadow); }
|
||||
.modal::backdrop { background: rgba(3,7,15,.72); backdrop-filter: blur(5px); }
|
||||
.modal-card { display: grid; grid-template-rows: auto 1fr auto; max-height: calc(100vh - 42px); }
|
||||
.modal-head, .modal-foot { display: flex; justify-content: space-between; align-items: center; padding: 16px 18px; border-bottom: 1px solid var(--border); }
|
||||
.modal-head h2 { margin: 0; font-size: 18px; }
|
||||
.modal-head p { margin: 3px 0 0; color: var(--muted); font-size: 11px; }
|
||||
.modal-body { overflow: auto; padding: 18px; }
|
||||
.modal-foot { border-bottom: 0; border-top: 1px solid var(--border); gap: 10px; }
|
||||
.modal-foot > div { display: flex; gap: 8px; }
|
||||
.target-choice { display: flex; gap: 10px; align-items: flex-start; padding: 12px; border-radius: 11px; background: rgba(122,162,255,.06); border: 1px solid rgba(122,162,255,.18); margin-bottom: 16px; }
|
||||
.target-choice span { display: grid; gap: 3px; }
|
||||
.target-choice strong { font-size: 12px; }
|
||||
.target-choice small { color: var(--muted); }
|
||||
.bulk-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; }
|
||||
.bulk-section { border: 1px solid var(--border); border-radius: 12px; padding: 13px; background: rgba(255,255,255,.018); }
|
||||
.bulk-section h3 { margin: 0 0 11px; font-size: 12px; }
|
||||
.bulk-section h3 small { color: var(--muted); font-weight: 400; }
|
||||
.bulk-control { display: grid; grid-template-columns: 20px 150px 1fr; gap: 8px; align-items: center; margin-top: 8px; font-size: 11px; }
|
||||
.bulk-control input, .bulk-control select { padding: 7px 8px; }
|
||||
.field.compact { margin-top: 9px; }
|
||||
.field.compact textarea { min-height: 60px; padding: 7px 8px; font-size: 11px; }
|
||||
.replace-section { margin-top: 15px; }
|
||||
.replace-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 9px; }
|
||||
.replace-options { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 10px; color: #c2cce1; font-size: 11px; }
|
||||
.replace-options label { display: flex; align-items: center; gap: 5px; }
|
||||
.preview-box { margin-top: 15px; border-radius: 11px; border: 1px solid var(--border); padding: 12px; font-size: 12px; }
|
||||
.preview-box.ok { color: #a1ebd3; background: rgba(71,215,167,.06); border-color: rgba(71,215,167,.2); }
|
||||
.preview-box.warn { color: #f6d99e; background: rgba(242,190,97,.06); border-color: rgba(242,190,97,.2); }
|
||||
.preview-samples { margin-top: 8px; display: grid; gap: 4px; max-height: 150px; overflow: auto; }
|
||||
.preview-samples code { color: #aec5f8; font-size: 10px; }
|
||||
|
||||
.toast-host { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 50; pointer-events: none; }
|
||||
.toast { max-width: 420px; padding: 11px 13px; border-radius: 10px; background: #18243c; border: 1px solid #334869; box-shadow: var(--shadow); font-size: 12px; animation: toast-in .18s ease-out; }
|
||||
.toast.error { border-color: rgba(255,111,127,.35); color: #ffc0c6; }
|
||||
.toast.success { border-color: rgba(71,215,167,.35); color: #a1ebd3; }
|
||||
@keyframes toast-in { from { opacity: 0; transform: translateY(8px); } }
|
||||
|
||||
@media (max-width: 950px) {
|
||||
body { overflow: auto; }
|
||||
.app-shell { grid-template-columns: 1fr; grid-template-rows: 70px minmax(420px, 48vh) auto; }
|
||||
.topbar { grid-row: 1; }
|
||||
.sidebar { grid-column: 1; grid-row: 2; border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.main-pane { grid-column: 1; grid-row: 3; min-height: 60vh; }
|
||||
.empty-cards { grid-template-columns: 1fr; }
|
||||
.span-2,.span-3,.span-4,.span-6,.span-8,.span-10 { grid-column: span 12; }
|
||||
.bulk-grid { grid-template-columns: 1fr; }
|
||||
.top-actions .pill { display: none; }
|
||||
}
|
||||
Reference in New Issue
Block a user