Freigabe-Anpassung und Soft-Trash
All checks were successful
release-tag / release-image (push) Successful in 1m34s
All checks were successful
release-tag / release-image (push) Successful in 1m34s
This commit is contained in:
@@ -5,6 +5,7 @@
|
||||
const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
|
||||
|
||||
const state = {
|
||||
scope: 'production',
|
||||
page: 1,
|
||||
pageSize: 60,
|
||||
total: 0,
|
||||
@@ -21,15 +22,19 @@
|
||||
|
||||
const els = {
|
||||
brandTitle: $('#brandTitle'), brandSubtitle: $('#brandSubtitle'), healthPill: $('#healthPill'), reloadBtn: $('#reloadBtn'), bulkBtn: $('#bulkBtn'),
|
||||
scopeProduction: $('#scopeProduction'), scopeStaging: $('#scopeStaging'), stagingCountBadge: $('#stagingCountBadge'),
|
||||
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'),
|
||||
filePath: $('#filePath'), dirtyBadge: $('#dirtyBadge'), stagingBadge: $('#stagingBadge'), saveBtn: $('#saveBtn'), formatJsonBtn: $('#formatJsonBtn'),
|
||||
deleteStagingBtn: $('#deleteStagingBtn'), promoteStagingBtn: $('#promoteStagingBtn'),
|
||||
formTab: $('#formTab'), rawTab: $('#rawTab'), rawEditor: $('#rawEditor'), rawError: $('#rawError'),
|
||||
bulkDialog: $('#bulkDialog'), bulkTargetText: $('#bulkTargetText'), bulkAllMatching: $('#bulkAllMatching'),
|
||||
allMatchingHint: $('#allMatchingHint'), bulkPreview: $('#bulkPreview'), previewBulkBtn: $('#previewBulkBtn'),
|
||||
applyBulkBtn: $('#applyBulkBtn'), toastHost: $('#toastHost')
|
||||
applyBulkBtn: $('#applyBulkBtn'), stagingBulkDialog: $('#stagingBulkDialog'), stagingBulkTargetText: $('#stagingBulkTargetText'),
|
||||
stagingBulkResult: $('#stagingBulkResult'), bulkDeleteStagingBtn: $('#bulkDeleteStagingBtn'), bulkPromoteStagingBtn: $('#bulkPromoteStagingBtn'),
|
||||
toastHost: $('#toastHost')
|
||||
};
|
||||
|
||||
let searchTimer;
|
||||
@@ -73,9 +78,11 @@
|
||||
document.title = config.title;
|
||||
}
|
||||
if (config.subtitle) els.brandSubtitle.textContent = config.subtitle;
|
||||
els.healthPill.textContent = `${h.count.toLocaleString('de-DE')} Dateien`;
|
||||
const stagingCount = Number(h.staging_count || 0);
|
||||
els.stagingCountBadge.textContent = stagingCount.toLocaleString('de-DE');
|
||||
els.healthPill.textContent = `${h.count.toLocaleString('de-DE')} produktiv · ${stagingCount.toLocaleString('de-DE')} Staging`;
|
||||
els.healthPill.className = 'pill ok';
|
||||
els.healthPill.title = `Daten: ${h.data_dir}\nBackups: ${h.backup_dir}`;
|
||||
els.healthPill.title = `Daten: ${h.data_dir}\nStaging: ${h.staging_dir || '–'}\nBackups: ${h.backup_dir || '–'}`;
|
||||
} catch (err) {
|
||||
els.healthPill.textContent = 'Offline';
|
||||
els.healthPill.className = 'pill';
|
||||
@@ -87,7 +94,8 @@
|
||||
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())}`);
|
||||
const endpoint = state.scope === 'staging' ? '/api/staging' : '/api/items';
|
||||
const data = await api(`${endpoint}?${queryString(currentQuery())}`);
|
||||
state.page = data.page || 1;
|
||||
state.total = data.total;
|
||||
state.totalPages = data.total_pages;
|
||||
@@ -105,14 +113,14 @@
|
||||
}
|
||||
for (const item of state.items) {
|
||||
const row = document.createElement('div');
|
||||
row.className = `result-item${item.key === state.currentKey ? ' active' : ''}`;
|
||||
row.className = `result-item${item.key === state.currentKey ? ' active' : ''}${state.scope === 'staging' ? ' staging-item' : ''}`;
|
||||
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-id">${state.scope === 'staging' ? '<span class="mini-staging">STAGING</span> ' : ''}${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>
|
||||
@@ -145,7 +153,8 @@
|
||||
|
||||
function renderSelectionOnly() {
|
||||
els.selectionCount.textContent = `${state.selected.size.toLocaleString('de-DE')} ausgewählt`;
|
||||
els.bulkBtn.disabled = state.selected.size === 0 && state.total === 0;
|
||||
els.bulkBtn.disabled = state.scope === 'staging' ? state.selected.size === 0 : (state.selected.size === 0 && state.total === 0);
|
||||
els.bulkBtn.textContent = state.scope === 'staging' ? '✦ Staging-Aktionen' : '✦ Massenbearbeitung';
|
||||
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));
|
||||
}
|
||||
@@ -156,7 +165,8 @@
|
||||
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)}`);
|
||||
const endpoint = state.scope === 'staging' ? '/api/staging' : '/api/items';
|
||||
const data = await api(`${endpoint}/${encodeURIComponent(key)}`);
|
||||
state.currentKey = key;
|
||||
state.currentDoc = data.document;
|
||||
state.currentMeta = data.meta;
|
||||
@@ -174,6 +184,10 @@
|
||||
els.emptyState.classList.add('hidden');
|
||||
els.editor.classList.remove('hidden');
|
||||
els.filePath.textContent = state.currentMeta?.rel_path || '–';
|
||||
const isStaging = state.scope === 'staging';
|
||||
els.stagingBadge.classList.toggle('hidden', !isStaging);
|
||||
els.promoteStagingBtn.classList.toggle('hidden', !isStaging);
|
||||
els.deleteStagingBtn.classList.toggle('hidden', !isStaging);
|
||||
setDirty(false);
|
||||
}
|
||||
|
||||
@@ -248,28 +262,41 @@
|
||||
}
|
||||
|
||||
async function saveCurrent() {
|
||||
if (!state.currentKey || !state.currentDoc) return;
|
||||
if (!state.currentKey || !state.currentDoc) return false;
|
||||
if (state.activeTab === 'raw') {
|
||||
if (!syncRawToDoc()) return;
|
||||
if (!syncRawToDoc()) return false;
|
||||
} else syncFormToDoc();
|
||||
|
||||
els.saveBtn.disabled = true;
|
||||
try {
|
||||
const result = await api(`/api/items/${encodeURIComponent(state.currentKey)}`, {
|
||||
const endpoint = state.scope === 'staging' ? '/api/staging' : '/api/items';
|
||||
const result = await api(`${endpoint}/${encodeURIComponent(state.currentKey)}`, {
|
||||
method: 'PUT', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(state.currentDoc)
|
||||
});
|
||||
state.currentMeta = result.meta;
|
||||
if (result.document) state.currentDoc = result.document;
|
||||
setDirty(false);
|
||||
toast(`Gespeichert. Backup: ${shortPath(result.backup)}`, 'success');
|
||||
if (state.scope === 'staging') toast('Staging-Entwurf gespeichert.', 'success');
|
||||
else toast(`Gespeichert. Backup: ${shortPath(result.backup)}`, 'success');
|
||||
await loadList(false);
|
||||
return true;
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
return false;
|
||||
} finally {
|
||||
els.saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function openBulk() {
|
||||
if (state.scope === 'staging') {
|
||||
if (state.selected.size === 0) return;
|
||||
els.stagingBulkTargetText.textContent = `${state.selected.size.toLocaleString('de-DE')} ausgewählte Staging-Entwürfe`;
|
||||
els.stagingBulkResult.className = 'preview-box hidden';
|
||||
els.stagingBulkResult.textContent = '';
|
||||
els.stagingBulkDialog.showModal();
|
||||
return;
|
||||
}
|
||||
if (state.selected.size === 0 && state.total === 0) return;
|
||||
resetBulkPreview();
|
||||
els.bulkAllMatching.checked = state.selected.size === 0;
|
||||
@@ -383,6 +410,96 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteCurrentStaging() {
|
||||
if (state.scope !== 'staging' || !state.currentKey) return;
|
||||
if (state.dirty) {
|
||||
if (!confirm('Der Entwurf enthält ungespeicherte Änderungen. Vor der Freigabe speichern?')) return;
|
||||
if (!await saveCurrent()) return;
|
||||
}
|
||||
if (!confirm('Diesen Staging-Entwurf jetzt unverändert in die produktive Wissensbasis freigeben?')) return;
|
||||
els.promoteStagingBtn.disabled = true;
|
||||
try {
|
||||
const result = await api(`/api/staging/${encodeURIComponent(state.currentKey)}/promote`, {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'}, body: '{}'
|
||||
});
|
||||
const productionKey = result.production?.key;
|
||||
toast(`Freigegeben: ${result.production?.rel_path || result.production?.id || 'Produktivartikel'}`, 'success');
|
||||
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false; state.selected.clear();
|
||||
await loadHealth();
|
||||
await setScope('production');
|
||||
if (productionKey) await openItem(productionKey);
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
els.promoteStagingBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCurrentStaging() {
|
||||
if (state.scope !== 'staging' || !state.currentKey) return;
|
||||
if (!confirm('Diesen Staging-Entwurf löschen? Er wird zur Sicherheit nach staging/.trash verschoben.')) return;
|
||||
els.deleteStagingBtn.disabled = true;
|
||||
try {
|
||||
await api(`/api/staging/${encodeURIComponent(state.currentKey)}`, {method: 'DELETE'});
|
||||
toast('Staging-Entwurf gelöscht und in .trash archiviert.', 'success');
|
||||
state.selected.delete(state.currentKey);
|
||||
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');
|
||||
} finally {
|
||||
els.deleteStagingBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stagingBulkAction(action) {
|
||||
if (state.scope !== 'staging' || state.selected.size === 0) return;
|
||||
const verb = action === 'promote' ? 'freigeben' : 'löschen';
|
||||
if (!confirm(`${state.selected.size.toLocaleString('de-DE')} Staging-Entwürfe wirklich ${verb}?`)) return;
|
||||
els.bulkPromoteStagingBtn.disabled = true;
|
||||
els.bulkDeleteStagingBtn.disabled = true;
|
||||
try {
|
||||
const result = await api('/api/staging/bulk', {
|
||||
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify({keys: Array.from(state.selected), action})
|
||||
});
|
||||
els.stagingBulkResult.className = `preview-box ${result.failed ? 'warn' : 'ok'}`;
|
||||
els.stagingBulkResult.innerHTML = `<strong>${result.succeeded.toLocaleString('de-DE')} erfolgreich</strong> · ${result.failed.toLocaleString('de-DE')} fehlgeschlagen` +
|
||||
(result.failed ? `<div class="preview-samples">${result.items.filter(x => !x.ok).slice(0,20).map(x => `<code>${escapeHTML(x.key)} · ${escapeHTML(x.error)}</code>`).join('')}</div>` : '');
|
||||
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()]);
|
||||
if (!result.failed) setTimeout(() => els.stagingBulkDialog.close(), 650);
|
||||
} catch (err) {
|
||||
els.stagingBulkResult.className = 'preview-box warn';
|
||||
els.stagingBulkResult.textContent = err.message;
|
||||
} finally {
|
||||
els.bulkPromoteStagingBtn.disabled = false;
|
||||
els.bulkDeleteStagingBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setScope(scope) {
|
||||
if (scope !== 'production' && scope !== 'staging') return;
|
||||
if (scope === state.scope) return;
|
||||
if (state.dirty && !confirm('Ungespeicherte Änderungen verwerfen und Bereich wechseln?')) return;
|
||||
state.scope = scope;
|
||||
state.page = 1;
|
||||
state.selected.clear();
|
||||
state.currentKey = null; state.currentDoc = null; state.currentMeta = null; state.dirty = false;
|
||||
els.scopeProduction.classList.toggle('active', scope === 'production');
|
||||
els.scopeStaging.classList.toggle('active', scope === 'staging');
|
||||
els.editor.classList.add('hidden'); els.emptyState.classList.remove('hidden');
|
||||
els.emptyState.querySelector('h1').textContent = scope === 'staging' ? 'Staging-Entwürfe prüfen' : 'JSON-Wissensbasis bearbeiten';
|
||||
els.emptyState.querySelector('p').textContent = scope === 'staging'
|
||||
? 'KI-generierte Entwürfe prüfen, bearbeiten und anschließend gezielt freigeben oder verwerfen.'
|
||||
: 'Wähle links einen Eintrag aus oder markiere mehrere Dateien für eine Massenänderung.';
|
||||
renderSelectionOnly();
|
||||
await loadList(true);
|
||||
}
|
||||
|
||||
function resetBulkPreview() {
|
||||
state.lastBulkPreviewSignature = '';
|
||||
els.bulkPreview.className = 'preview-box hidden';
|
||||
@@ -408,6 +525,13 @@
|
||||
searchTimer = setTimeout(() => loadList(true), 240);
|
||||
}
|
||||
|
||||
els.scopeProduction.addEventListener('click', () => setScope('production'));
|
||||
els.scopeStaging.addEventListener('click', () => setScope('staging'));
|
||||
els.promoteStagingBtn.addEventListener('click', promoteCurrentStaging);
|
||||
els.deleteStagingBtn.addEventListener('click', deleteCurrentStaging);
|
||||
els.bulkPromoteStagingBtn.addEventListener('click', () => stagingBulkAction('promote'));
|
||||
els.bulkDeleteStagingBtn.addEventListener('click', () => stagingBulkAction('delete'));
|
||||
|
||||
// Filters and navigation.
|
||||
[els.searchInput, els.languageFilter, els.sourceFilter, els.styleFilter].forEach(el => el.addEventListener('input', debounceReload));
|
||||
els.autoReplyFilter.addEventListener('change', () => loadList(true));
|
||||
|
||||
@@ -25,6 +25,10 @@
|
||||
|
||||
<aside class="sidebar">
|
||||
<section class="filters">
|
||||
<div class="scope-switch" role="group" aria-label="Datenbereich">
|
||||
<button id="scopeProduction" type="button" class="scope-btn active" data-scope="production">Produktiv</button>
|
||||
<button id="scopeStaging" type="button" class="scope-btn" data-scope="staging">Staging <span id="stagingCountBadge">0</span></button>
|
||||
</div>
|
||||
<label class="search-wrap">
|
||||
<span>⌕</span>
|
||||
<input id="searchInput" type="search" placeholder="Code, Titel, Text, Keyword …" autocomplete="off">
|
||||
@@ -77,9 +81,12 @@
|
||||
<div class="editor-head">
|
||||
<div class="breadcrumb">
|
||||
<span id="filePath">–</span>
|
||||
<span id="stagingBadge" class="badge staging hidden">AI-STAGING · UNGEPRÜFT</span>
|
||||
<span id="dirtyBadge" class="badge warn hidden">Ungespeichert</span>
|
||||
</div>
|
||||
<div class="editor-actions">
|
||||
<button id="deleteStagingBtn" class="btn danger hidden">Löschen</button>
|
||||
<button id="promoteStagingBtn" class="btn primary hidden">✓ Freigeben → Produktiv</button>
|
||||
<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>
|
||||
@@ -245,6 +252,34 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
|
||||
<dialog id="stagingBulkDialog" class="modal staging-modal">
|
||||
<form method="dialog" class="modal-card">
|
||||
<header class="modal-head">
|
||||
<div>
|
||||
<h2>Staging-Aktionen</h2>
|
||||
<p id="stagingBulkTargetText">–</p>
|
||||
</div>
|
||||
<button value="cancel" class="icon-btn" aria-label="Schließen">×</button>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<div class="staging-info-card">
|
||||
<strong>Review-Workflow</strong>
|
||||
<p>Freigeben kopiert den aktuellen JSON-Inhalt unverändert in die produktive Wissensbasis und archiviert den geprüften Originalentwurf anschließend unter <code>staging/.approved</code>. <code>auto_reply</code> bleibt genau wie im Entwurf gesetzt.</p>
|
||||
</div>
|
||||
<div id="stagingBulkResult" class="preview-box hidden"></div>
|
||||
</div>
|
||||
<footer class="modal-foot">
|
||||
<span class="muted-text">Löschen verschiebt Entwürfe sicher nach <code>staging/.trash</code>.</span>
|
||||
<div>
|
||||
<button value="cancel" class="btn ghost">Abbrechen</button>
|
||||
<button id="bulkDeleteStagingBtn" type="button" class="btn danger">Auswahl löschen</button>
|
||||
<button id="bulkPromoteStagingBtn" type="button" class="btn primary">Auswahl freigeben</button>
|
||||
</div>
|
||||
</footer>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<div id="toastHost" class="toast-host" aria-live="polite"></div>
|
||||
<script src="/app.js" defer></script>
|
||||
</body>
|
||||
|
||||
@@ -197,3 +197,62 @@ textarea { resize: vertical; line-height: 1.45; }
|
||||
.bulk-grid { grid-template-columns: 1fr; }
|
||||
.top-actions .pill { display: none; }
|
||||
}
|
||||
|
||||
/* Review workflow: production vs. AI staging */
|
||||
.scope-switch {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 5px;
|
||||
padding: 4px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 11px;
|
||||
background: #0b1323;
|
||||
}
|
||||
.scope-btn {
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
color: var(--muted);
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
transition: .16s ease;
|
||||
}
|
||||
.scope-btn:hover { color: var(--text); background: rgba(255,255,255,.035); }
|
||||
.scope-btn.active { color: var(--text); background: var(--panel-3); box-shadow: inset 0 0 0 1px rgba(122,162,255,.22); }
|
||||
.scope-btn span {
|
||||
display: inline-grid;
|
||||
min-width: 20px;
|
||||
place-items: center;
|
||||
margin-left: 5px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
font-size: 9px;
|
||||
color: #f7d58f;
|
||||
background: rgba(242,190,97,.12);
|
||||
border: 1px solid rgba(242,190,97,.22);
|
||||
}
|
||||
.result-item.staging-item { background-image: linear-gradient(90deg, rgba(242,190,97,.035), transparent 45%); }
|
||||
.result-item.staging-item.active { background: rgba(242,190,97,.075); box-shadow: inset 3px 0 0 var(--warning); }
|
||||
.mini-staging {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
border-radius: 5px;
|
||||
color: #f6d99e;
|
||||
background: rgba(242,190,97,.10);
|
||||
border: 1px solid rgba(242,190,97,.2);
|
||||
font: 800 8px/1.4 ui-sans-serif, system-ui, sans-serif;
|
||||
letter-spacing: .07em;
|
||||
}
|
||||
.badge.staging { color: #f6d99e; border-color: rgba(242,190,97,.28); background: rgba(242,190,97,.07); }
|
||||
.staging-modal { width: min(720px, calc(100vw - 40px)); }
|
||||
.staging-info-card {
|
||||
border: 1px solid rgba(242,190,97,.22);
|
||||
border-radius: 12px;
|
||||
padding: 15px;
|
||||
background: rgba(242,190,97,.055);
|
||||
}
|
||||
.staging-info-card strong { display: block; margin-bottom: 6px; color: #f7dca8; }
|
||||
.staging-info-card p { margin: 0; color: #c7cede; font-size: 12px; line-height: 1.6; }
|
||||
.staging-info-card code, .modal-foot code { color: #f6d99e; }
|
||||
|
||||
Reference in New Issue
Block a user