5 new features: dark mode, shortcuts, SSE tasks, compare, compose
Dark mode: - body.dark overrides CSS variables (navy→cream reverse) - Persisted in localStorage, applied before paint to avoid flash - Toggle button in nav (moon/sun icon), Shift+D shortcut Keyboard shortcuts: - g h/n/u/t/s/c/w/d/k for page navigation - n for new case, ? for help (Shift+/) - Esc closes any open dialog, blurs focused input - Help modal via showShortcutsHelp() with styled kbd elements SSE tasks stream: - /api/system/tasks/stream pushes snapshots whenever _progress changes - Client uses EventSource instead of 3s polling - Auto-reconnect after 5s on error - 15s heartbeat keeps proxies alive Compare decisions (new #/compare page): - /api/training/compare?a=id&b=id serializes both decisions' metadata, section breakdown from document_chunks, and three buckets of patterns (only in A, only in B, shared) using variant matching - Two-column header with section-length breakdown + patterns count - Three-column diff row (only_a / shared / only_b) Compose with suggestions (new #/compose page): - Large RTL justified textarea with Hebrew display font title input - Sidebar lists all 47 style_patterns grouped by type with filter chips - Click a pattern → inserts at cursor, replacing [placeholders] with ___ - Live section guess (פתיחה / רקע / טענות / דיון / סוף דבר) based on most-recent 400 chars - Auto-save draft to localStorage every second; restore on page load - "העתק טקסט" copies title+body to clipboard Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,6 +83,21 @@ header nav a.active::after {
|
||||
background: var(--color-gold);
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
.theme-toggle {
|
||||
background: transparent;
|
||||
border: 1px solid rgba(169, 125, 58, 0.4);
|
||||
color: var(--color-gold-soft);
|
||||
cursor: pointer;
|
||||
font-size: 1.05em;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius);
|
||||
margin-right: var(--space-3);
|
||||
transition: all var(--t);
|
||||
}
|
||||
.theme-toggle:hover {
|
||||
background: rgba(169, 125, 58, 0.15);
|
||||
border-color: var(--color-gold);
|
||||
}
|
||||
|
||||
/* Main */
|
||||
.main { flex: 1; max-width: 1200px; width: 100%; margin: 0 auto; padding: var(--space-7) var(--space-6); }
|
||||
@@ -876,6 +891,247 @@ header nav a.active::after {
|
||||
.diag-row-title { font-weight: 600; color: var(--color-navy); margin-bottom: 2px; }
|
||||
.diag-row-meta { font-size: 0.88em; color: var(--color-ink-muted); }
|
||||
|
||||
/* ── Compare Decisions ───────────────────────────────── */
|
||||
.compare-pickers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto 1fr auto;
|
||||
gap: var(--space-4);
|
||||
align-items: end;
|
||||
}
|
||||
.compare-vs {
|
||||
font-size: 1.5em;
|
||||
color: var(--color-gold);
|
||||
padding-bottom: 10px;
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
.compare-result-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-5);
|
||||
margin-bottom: var(--space-5);
|
||||
}
|
||||
.compare-column {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-rule-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-6);
|
||||
border-top: 4px solid var(--color-gold);
|
||||
}
|
||||
.compare-col-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.2em;
|
||||
font-weight: 800;
|
||||
color: var(--color-navy);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
.compare-col-meta {
|
||||
font-size: 0.82em;
|
||||
color: var(--color-ink-muted);
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-4);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 1px solid var(--color-rule-soft);
|
||||
}
|
||||
.compare-col-stat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 6px 0;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.compare-col-stat strong { color: var(--color-navy); }
|
||||
|
||||
.compare-diff-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
.compare-diff-col {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-rule-soft);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
.compare-diff-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
color: var(--color-navy);
|
||||
margin-bottom: var(--space-3);
|
||||
padding-bottom: var(--space-2);
|
||||
border-bottom: 2px solid var(--color-gold);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
.compare-diff-count {
|
||||
background: var(--color-gold-wash);
|
||||
color: var(--color-gold-deep);
|
||||
padding: 2px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.78em;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
.compare-diff-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.compare-diff-item {
|
||||
padding: 8px 12px;
|
||||
background: var(--color-cream);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.82em;
|
||||
color: var(--color-ink);
|
||||
border-right: 2px solid var(--color-gold);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Compose (writing assistant) ─────────────────────── */
|
||||
.compose-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: var(--space-5);
|
||||
min-height: 70vh;
|
||||
}
|
||||
.compose-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
.compose-toolbar {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
.compose-title-input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.15em;
|
||||
font-weight: 600;
|
||||
color: var(--color-navy);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-rule);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.compose-title-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-gold);
|
||||
box-shadow: 0 0 0 3px var(--color-gold-wash);
|
||||
}
|
||||
.compose-textarea {
|
||||
flex: 1;
|
||||
min-height: 60vh;
|
||||
padding: var(--space-6) var(--space-7);
|
||||
font-family: var(--font-body);
|
||||
font-size: 1.02em;
|
||||
line-height: 1.75;
|
||||
color: var(--color-ink);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-rule);
|
||||
border-radius: var(--radius-lg);
|
||||
resize: vertical;
|
||||
text-align: justify;
|
||||
text-justify: inter-word;
|
||||
direction: rtl;
|
||||
}
|
||||
.compose-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-gold);
|
||||
box-shadow: 0 0 0 3px var(--color-gold-wash);
|
||||
}
|
||||
.compose-stats {
|
||||
font-size: 0.82em;
|
||||
color: var(--color-ink-muted);
|
||||
padding: 6px 14px;
|
||||
background: var(--color-parchment);
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--color-rule-soft);
|
||||
}
|
||||
.compose-stats span { color: var(--color-navy); font-weight: 600; }
|
||||
|
||||
.compose-sidebar {
|
||||
background: var(--color-parchment);
|
||||
border: 1px solid var(--color-rule);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-3);
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.compose-sidebar-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.05em;
|
||||
font-weight: 700;
|
||||
color: var(--color-navy);
|
||||
padding-bottom: var(--space-3);
|
||||
border-bottom: 2px solid var(--color-gold);
|
||||
}
|
||||
.compose-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.compose-filter {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--color-rule);
|
||||
background: var(--color-surface);
|
||||
font-size: 0.76em;
|
||||
cursor: pointer;
|
||||
transition: all var(--t-fast);
|
||||
}
|
||||
.compose-filter:hover { background: var(--color-gold-wash); }
|
||||
.compose-filter.active {
|
||||
background: var(--color-navy);
|
||||
color: var(--color-parchment);
|
||||
border-color: var(--color-navy);
|
||||
}
|
||||
.compose-patterns {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
.compose-pattern {
|
||||
padding: 10px 12px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-rule-soft);
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.84em;
|
||||
cursor: pointer;
|
||||
transition: all var(--t-fast);
|
||||
border-right: 3px solid var(--color-gold);
|
||||
line-height: 1.4;
|
||||
}
|
||||
.compose-pattern:hover {
|
||||
background: var(--color-gold-wash);
|
||||
transform: translateX(-2px);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.compose-pattern-text {
|
||||
color: var(--color-navy);
|
||||
font-weight: 500;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.compose-pattern-meta {
|
||||
font-size: 0.78em;
|
||||
color: var(--color-ink-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.compose-layout { grid-template-columns: 1fr; }
|
||||
.compare-result-grid, .compare-diff-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* ── Local files (research, drafts, proofread) ───────── */
|
||||
.local-file-group { margin-bottom: 12px; }
|
||||
.local-file-group-header { font-size: 0.82em; font-weight: 600; color: #666; margin-bottom: 6px; padding-bottom: 4px; border-bottom: 1px solid #eee; }
|
||||
@@ -1366,8 +1622,43 @@ header nav a.active::after {
|
||||
max-height: 300px; overflow-y: auto;
|
||||
}
|
||||
.phrase-modal-example {
|
||||
padding: 10px 14px; background: #fafafa; border-right: 3px solid var(--color-gold);
|
||||
font-size: 0.86em; line-height: 1.5; color: #333; border-radius: 4px;
|
||||
padding: 10px 14px; background: var(--color-cream); border-right: 3px solid var(--color-gold);
|
||||
font-size: 0.86em; line-height: 1.5; color: var(--color-ink); border-radius: var(--radius);
|
||||
}
|
||||
|
||||
/* Shortcuts help dialog */
|
||||
.shortcuts-dialog { max-width: 460px; }
|
||||
.shortcuts-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.88em;
|
||||
}
|
||||
.shortcuts-table tr { border-bottom: 1px solid var(--color-rule-soft); }
|
||||
.shortcuts-table tr:last-child { border-bottom: none; }
|
||||
.shortcuts-table td {
|
||||
padding: 10px 8px;
|
||||
color: var(--color-ink);
|
||||
}
|
||||
.shortcut-keys {
|
||||
text-align: left;
|
||||
direction: ltr;
|
||||
unicode-bidi: embed;
|
||||
white-space: nowrap;
|
||||
width: 120px;
|
||||
}
|
||||
kbd {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
background: var(--color-parchment);
|
||||
border: 1px solid var(--color-rule);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
color: var(--color-navy);
|
||||
margin: 0 2px;
|
||||
min-width: 22px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 800px) {
|
||||
@@ -1391,8 +1682,13 @@ header nav a.active::after {
|
||||
<a href="#/upload" id="navUpload">העלאה</a>
|
||||
<a href="#/training" id="navTraining">אימון סגנון</a>
|
||||
<a href="#/style-report" id="navStyleReport">הסגנון שלי</a>
|
||||
<a href="#/compare" id="navCompare">השוואה</a>
|
||||
<a href="#/compose" id="navCompose">כתיבה</a>
|
||||
<a href="#/skills" id="navSkills">Skills</a>
|
||||
<a href="#/diagnostics" id="navDiagnostics">מצב מערכת</a>
|
||||
<button class="theme-toggle" id="themeToggle" onclick="toggleTheme()" title="החלף ערכת צבעים (Shift+D)">
|
||||
<span id="themeIcon">🌙</span>
|
||||
</button>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -1827,6 +2123,60 @@ header nav a.active::after {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Page: Compare Decisions ══ -->
|
||||
<div class="page" id="page-compare">
|
||||
<div class="page-header">
|
||||
<h2>השוואת שתי החלטות</h2>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="compare-pickers">
|
||||
<div class="form-group">
|
||||
<label>החלטה א'</label>
|
||||
<select id="compareSelectA"><option value="">בחרי החלטה...</option></select>
|
||||
</div>
|
||||
<div class="compare-vs">⟷</div>
|
||||
<div class="form-group">
|
||||
<label>החלטה ב'</label>
|
||||
<select id="compareSelectB"><option value="">בחרי החלטה...</option></select>
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="runCompare()">השווה</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="compareResult"></div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Page: Compose (writing with live suggestions) ══ -->
|
||||
<div class="page" id="page-compose">
|
||||
<div class="page-header">
|
||||
<h2>כתיבת החלטה — עם הצעות סגנון</h2>
|
||||
</div>
|
||||
<div class="compose-layout">
|
||||
<div class="compose-main">
|
||||
<div class="compose-toolbar">
|
||||
<input type="text" id="composeTitle" placeholder="כותרת ההחלטה (למשל: ערר 1045/25 — יעקב כהן)" class="compose-title-input">
|
||||
<button class="btn btn-ghost btn-sm" onclick="copyComposeText()">העתק טקסט</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="saveComposeDraft()">שמור טיוטה</button>
|
||||
</div>
|
||||
<textarea id="composeTextarea" class="compose-textarea"
|
||||
placeholder="התחילי לכתוב כאן. הצעות סגנון יופיעו בצד בהתאם לתוכן..."></textarea>
|
||||
<div class="compose-stats">
|
||||
<span id="composeCharCount">0</span> תווים ·
|
||||
<span id="composeWordCount">0</span> מילים ·
|
||||
<span id="composeSectionGuess">—</span>
|
||||
</div>
|
||||
</div>
|
||||
<aside class="compose-sidebar">
|
||||
<h3 class="compose-sidebar-title">ביטויים של דפנה</h3>
|
||||
<div class="compose-filters" id="composeFilters"></div>
|
||||
<div class="compose-patterns" id="composePatterns">
|
||||
<div class="empty" style="padding:20px">טוען...</div>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ══ Page: Diagnostics ══ -->
|
||||
<div class="page" id="page-diagnostics">
|
||||
<div class="page-header">
|
||||
@@ -1936,16 +2286,133 @@ function handleRoute() {
|
||||
document.getElementById('navDiagnostics').classList.add('active');
|
||||
subtitle = 'מצב מערכת';
|
||||
loadDiagnostics();
|
||||
} else if (hash === '#/compare') {
|
||||
document.getElementById('page-compare').classList.add('active');
|
||||
document.getElementById('navCompare').classList.add('active');
|
||||
subtitle = 'השוואת החלטות';
|
||||
initComparePage();
|
||||
} else if (hash === '#/compose') {
|
||||
document.getElementById('page-compose').classList.add('active');
|
||||
document.getElementById('navCompose').classList.add('active');
|
||||
subtitle = 'כתיבת החלטה';
|
||||
initComposePage();
|
||||
}
|
||||
|
||||
document.getElementById('pageSubtitle').textContent = subtitle;
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', handleRoute);
|
||||
// ── Keyboard shortcuts ──────────────────────────────
|
||||
|
||||
const SHORTCUTS = [
|
||||
{ keys: ['g', 'h'], label: 'דף הבית', action: () => location.hash = '#/' },
|
||||
{ keys: ['g', 'n'], label: 'תיק חדש', action: () => location.hash = '#/new' },
|
||||
{ keys: ['g', 'u'], label: 'העלאה', action: () => location.hash = '#/upload' },
|
||||
{ keys: ['g', 't'], label: 'אימון סגנון', action: () => location.hash = '#/training' },
|
||||
{ keys: ['g', 's'], label: 'הסגנון שלי', action: () => location.hash = '#/style-report' },
|
||||
{ keys: ['g', 'c'], label: 'השוואה', action: () => location.hash = '#/compare' },
|
||||
{ keys: ['g', 'w'], label: 'כתיבת החלטה', action: () => location.hash = '#/compose' },
|
||||
{ keys: ['g', 'd'], label: 'מצב מערכת', action: () => location.hash = '#/diagnostics' },
|
||||
{ keys: ['g', 'k'], label: 'Skills', action: () => location.hash = '#/skills' },
|
||||
{ keys: ['n'], label: 'תיק חדש', action: () => location.hash = '#/new' },
|
||||
{ keys: ['D'], label: 'Dark mode', shiftKey: true, action: toggleTheme },
|
||||
{ keys: ['?'], label: 'עזרה', shiftKey: true, action: () => showShortcutsHelp() },
|
||||
{ keys: ['Escape'], label: 'סגור', action: () => {
|
||||
document.querySelectorAll('dialog[open]').forEach(d => d.close());
|
||||
}},
|
||||
];
|
||||
|
||||
let _pendingKey = null;
|
||||
let _pendingTimer = null;
|
||||
|
||||
function setupKeyboardShortcuts() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ignore when typing in inputs
|
||||
const tag = (e.target.tagName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || e.target.isContentEditable) {
|
||||
if (e.key === 'Escape') e.target.blur();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle immediate-single shortcuts
|
||||
for (const s of SHORTCUTS) {
|
||||
if (s.keys.length === 1 && s.keys[0] === e.key) {
|
||||
if (s.shiftKey !== undefined && e.shiftKey !== s.shiftKey) continue;
|
||||
e.preventDefault();
|
||||
s.action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle 2-key chords starting with 'g'
|
||||
if (e.key === 'g' && !_pendingKey) {
|
||||
_pendingKey = 'g';
|
||||
_pendingTimer = setTimeout(() => { _pendingKey = null; }, 1500);
|
||||
return;
|
||||
}
|
||||
if (_pendingKey === 'g') {
|
||||
clearTimeout(_pendingTimer);
|
||||
_pendingKey = null;
|
||||
for (const s of SHORTCUTS) {
|
||||
if (s.keys.length === 2 && s.keys[0] === 'g' && s.keys[1] === e.key) {
|
||||
e.preventDefault();
|
||||
s.action();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function showShortcutsHelp() {
|
||||
let dialog = document.getElementById('shortcutsDialog');
|
||||
if (!dialog) {
|
||||
dialog = document.createElement('dialog');
|
||||
dialog.id = 'shortcutsDialog';
|
||||
dialog.className = 'phrase-modal shortcuts-dialog';
|
||||
document.body.appendChild(dialog);
|
||||
}
|
||||
const rows = SHORTCUTS.map(s => {
|
||||
const keysDisplay = s.keys.map(k => `<kbd>${k}</kbd>`).join(' ');
|
||||
return `<tr><td class="shortcut-keys">${keysDisplay}</td><td>${esc(s.label)}</td></tr>`;
|
||||
}).join('');
|
||||
dialog.innerHTML = `
|
||||
<div class="phrase-modal-header">
|
||||
<span class="phrase-modal-type">קיצורי מקלדת</span>
|
||||
<button class="btn-icon" onclick="document.getElementById('shortcutsDialog').close()">✕</button>
|
||||
</div>
|
||||
<table class="shortcuts-table">${rows}</table>
|
||||
`;
|
||||
dialog.showModal();
|
||||
}
|
||||
|
||||
// ── Theme (dark/light) ──────────────────────────────
|
||||
|
||||
function applyTheme(dark) {
|
||||
document.body.classList.toggle('dark', dark);
|
||||
const icon = document.getElementById('themeIcon');
|
||||
if (icon) icon.textContent = dark ? '☀️' : '🌙';
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const dark = !document.body.classList.contains('dark');
|
||||
applyTheme(dark);
|
||||
try { localStorage.setItem('theme', dark ? 'dark' : 'light'); } catch (e) {}
|
||||
}
|
||||
|
||||
// Apply saved theme immediately (before onload to prevent flash)
|
||||
(function initTheme() {
|
||||
try {
|
||||
const saved = localStorage.getItem('theme');
|
||||
if (saved === 'dark') applyTheme(true);
|
||||
} catch (e) {}
|
||||
})();
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
handleRoute();
|
||||
loadStatus();
|
||||
startProcessPanelPolling();
|
||||
setupKeyboardShortcuts();
|
||||
});
|
||||
|
||||
// ── Case List ────────────────────────────────────────────
|
||||
@@ -2702,6 +3169,271 @@ async function loadStatus() {
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
// ── Compare Decisions ────────────────────────────────
|
||||
|
||||
async function initComparePage() {
|
||||
const selA = document.getElementById('compareSelectA');
|
||||
const selB = document.getElementById('compareSelectB');
|
||||
if (selA.options.length > 1) return; // already loaded
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/training/corpus');
|
||||
const rows = await res.json();
|
||||
const options = rows.map(r => {
|
||||
const label = `${r.decision_number || '(ללא מספר)'} · ${r.decision_date || '—'}`;
|
||||
return `<option value="${esc(r.id)}">${esc(label)}</option>`;
|
||||
}).join('');
|
||||
selA.innerHTML = '<option value="">בחרי החלטה...</option>' + options;
|
||||
selB.innerHTML = '<option value="">בחרי החלטה...</option>' + options;
|
||||
} catch (e) {
|
||||
toast('שגיאה בטעינת רשימת החלטות', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function runCompare() {
|
||||
const a = document.getElementById('compareSelectA').value;
|
||||
const b = document.getElementById('compareSelectB').value;
|
||||
if (!a || !b) { toast('בחרי שתי החלטות', 'error'); return; }
|
||||
if (a === b) { toast('יש לבחור שתי החלטות שונות', 'error'); return; }
|
||||
|
||||
const result = document.getElementById('compareResult');
|
||||
result.innerHTML = '<div class="card"><div class="card-body"><div class="empty">משווה...</div></div></div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/training/compare?a=' + encodeURIComponent(a) + '&b=' + encodeURIComponent(b));
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
const data = await res.json();
|
||||
renderCompareResult(data);
|
||||
} catch (e) {
|
||||
result.innerHTML = `<div class="card"><div class="card-body"><div class="empty">שגיאה: ${esc(e.message)}</div></div></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderCompareResult(data) {
|
||||
const container = document.getElementById('compareResult');
|
||||
const a = data.a, b = data.b;
|
||||
|
||||
const headerCard = (row, label) => {
|
||||
const secTotal = row.sections.reduce((sum, s) => sum + s.chars, 0) || 1;
|
||||
const sectionsHtml = row.sections.map(s => {
|
||||
const pct = Math.round((s.chars / secTotal) * 100);
|
||||
const labelHeb = ({
|
||||
intro: 'פתיחה', facts: 'רקע', appellant_claims: 'טענות עורר',
|
||||
respondent_claims: 'טענות משיב', legal_analysis: 'דיון משפטי',
|
||||
ruling: 'הכרעה', conclusion: 'סוף דבר',
|
||||
})[s.type] || s.type;
|
||||
return `<div class="compare-col-stat"><span>${esc(labelHeb)}</span><strong>${s.chars.toLocaleString('he-IL')} (${pct}%)</strong></div>`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="compare-column">
|
||||
<div class="compare-col-title">${esc(row.decision_number || '(ללא מספר)')}</div>
|
||||
<div class="compare-col-meta">
|
||||
<span>${esc(row.decision_date || '—')}</span>
|
||||
<span>${row.chars.toLocaleString('he-IL')} תווים</span>
|
||||
</div>
|
||||
<div class="compare-col-stat"><span>דפוסים שזוהו</span><strong>${row.patterns_count}</strong></div>
|
||||
${sectionsHtml}
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
const diffColumn = (title, items, colorClass) => {
|
||||
if (!items.length) {
|
||||
return `
|
||||
<div class="compare-diff-col">
|
||||
<div class="compare-diff-title">${esc(title)} <span class="compare-diff-count">0</span></div>
|
||||
<div class="empty" style="padding:16px">—</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const itemsHtml = items.map(p => {
|
||||
let display = p.text.replace(/\[[^\]]*\]/g, '').replace(/\s+/g, ' ').trim();
|
||||
display = display.split(' / ')[0].split(' או ')[0].trim();
|
||||
if (display.length > 80) display = display.substring(0, 77) + '...';
|
||||
return `<div class="compare-diff-item">${esc(display)}</div>`;
|
||||
}).join('');
|
||||
return `
|
||||
<div class="compare-diff-col">
|
||||
<div class="compare-diff-title">${esc(title)} <span class="compare-diff-count">${items.length}</span></div>
|
||||
<div class="compare-diff-list">${itemsHtml}</div>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="compare-result-grid">
|
||||
${headerCard(a)}
|
||||
${headerCard(b)}
|
||||
</div>
|
||||
<div class="compare-diff-row">
|
||||
${diffColumn('רק ב-' + (a.decision_number || 'א'), data.only_a)}
|
||||
${diffColumn('משותפים', data.shared)}
|
||||
${diffColumn('רק ב-' + (b.decision_number || 'ב'), data.only_b)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── Compose (writing with suggestions) ───────────────
|
||||
|
||||
let _composePatterns = [];
|
||||
let _composeFilter = 'all';
|
||||
|
||||
async function initComposePage() {
|
||||
// Restore draft if any
|
||||
try {
|
||||
const saved = localStorage.getItem('composeDraft');
|
||||
const title = localStorage.getItem('composeTitle');
|
||||
if (saved) document.getElementById('composeTextarea').value = saved;
|
||||
if (title) document.getElementById('composeTitle').value = title;
|
||||
} catch (e) {}
|
||||
|
||||
updateComposeStats();
|
||||
document.getElementById('composeTextarea').addEventListener('input', onComposeInput);
|
||||
|
||||
if (_composePatterns.length) {
|
||||
renderComposePatterns();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(API + '/training/patterns');
|
||||
const data = await res.json();
|
||||
_composePatterns = [];
|
||||
Object.entries(data.by_type || {}).forEach(([type, items]) => {
|
||||
items.forEach(p => _composePatterns.push({ type, ...p }));
|
||||
});
|
||||
// Sort by frequency desc
|
||||
_composePatterns.sort((a, b) => (b.frequency || 0) - (a.frequency || 0));
|
||||
renderComposeFilters();
|
||||
renderComposePatterns();
|
||||
} catch (e) {
|
||||
document.getElementById('composePatterns').innerHTML = '<div class="empty">שגיאה בטעינה</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function renderComposeFilters() {
|
||||
const types = [...new Set(_composePatterns.map(p => p.type))];
|
||||
const typeLabels = {
|
||||
opening_formula: 'פתיחה',
|
||||
closing_formula: 'סיום',
|
||||
transition: 'מעברים',
|
||||
characteristic_phrase: 'ביטויים',
|
||||
argument_flow: 'טיעון',
|
||||
analysis_structure: 'מבנה',
|
||||
evidence_handling: 'ראיות',
|
||||
citation_style: 'ציטוט',
|
||||
};
|
||||
const all = [{ id: 'all', label: 'הכל' }]
|
||||
.concat(types.map(t => ({ id: t, label: typeLabels[t] || t })));
|
||||
document.getElementById('composeFilters').innerHTML = all.map(f => `
|
||||
<div class="compose-filter ${f.id === _composeFilter ? 'active' : ''}"
|
||||
onclick="setComposeFilter('${f.id}')">${esc(f.label)}</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function setComposeFilter(f) {
|
||||
_composeFilter = f;
|
||||
renderComposeFilters();
|
||||
renderComposePatterns();
|
||||
}
|
||||
|
||||
function renderComposePatterns() {
|
||||
const filtered = _composeFilter === 'all'
|
||||
? _composePatterns
|
||||
: _composePatterns.filter(p => p.type === _composeFilter);
|
||||
|
||||
const typeLabels = {
|
||||
opening_formula: 'פתיחה', closing_formula: 'סיום', transition: 'מעבר',
|
||||
characteristic_phrase: 'ביטוי', argument_flow: 'טיעון',
|
||||
analysis_structure: 'מבנה', evidence_handling: 'ראיות', citation_style: 'ציטוט',
|
||||
};
|
||||
|
||||
document.getElementById('composePatterns').innerHTML = filtered.map((p, idx) => {
|
||||
let display = p.pattern_text || p.text || '';
|
||||
let clean = display.replace(/\[[^\]]*\]/g, '').replace(/\s+/g, ' ').trim();
|
||||
clean = clean.split(' / ')[0].split(' או ')[0].trim();
|
||||
if (clean.length > 90) clean = clean.substring(0, 87) + '...';
|
||||
const origIdx = _composePatterns.indexOf(p);
|
||||
return `
|
||||
<div class="compose-pattern" onclick="insertComposePattern(${origIdx})">
|
||||
<div class="compose-pattern-text">${esc(clean)}</div>
|
||||
<div class="compose-pattern-meta">${esc(typeLabels[p.type] || p.type)} · ${p.frequency || 0}/24</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('') || '<div class="empty" style="padding:20px">אין דפוסים בקטגוריה זו</div>';
|
||||
}
|
||||
|
||||
function insertComposePattern(idx) {
|
||||
const p = _composePatterns[idx];
|
||||
if (!p) return;
|
||||
let text = (p.pattern_text || p.text || '').replace(/\[[^\]]*\]/g, '___').replace(/\s+/g, ' ').trim();
|
||||
text = text.split(' / ')[0].split(' או ')[0].trim();
|
||||
|
||||
const ta = document.getElementById('composeTextarea');
|
||||
const start = ta.selectionStart;
|
||||
const end = ta.selectionEnd;
|
||||
const before = ta.value.substring(0, start);
|
||||
const after = ta.value.substring(end);
|
||||
// Add space before if needed
|
||||
const prefix = (before && !/\s$/.test(before)) ? ' ' : '';
|
||||
const suffix = (after && !/^\s/.test(after)) ? ' ' : '';
|
||||
const insert = prefix + text + suffix;
|
||||
ta.value = before + insert + after;
|
||||
const newPos = start + insert.length;
|
||||
ta.selectionStart = ta.selectionEnd = newPos;
|
||||
ta.focus();
|
||||
onComposeInput();
|
||||
}
|
||||
|
||||
function onComposeInput() {
|
||||
updateComposeStats();
|
||||
// Auto-save draft every second (debounced)
|
||||
clearTimeout(onComposeInput._t);
|
||||
onComposeInput._t = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem('composeDraft', document.getElementById('composeTextarea').value);
|
||||
localStorage.setItem('composeTitle', document.getElementById('composeTitle').value);
|
||||
} catch (e) {}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function updateComposeStats() {
|
||||
const ta = document.getElementById('composeTextarea');
|
||||
if (!ta) return;
|
||||
const text = ta.value || '';
|
||||
const chars = text.length;
|
||||
const words = (text.match(/\S+/g) || []).length;
|
||||
document.getElementById('composeCharCount').textContent = chars.toLocaleString('he-IL');
|
||||
document.getElementById('composeWordCount').textContent = words.toLocaleString('he-IL');
|
||||
|
||||
// Guess section from recent text
|
||||
const recent = text.slice(-400);
|
||||
let section = '—';
|
||||
if (/בפנינו|לפנינו|עניינו|עסקינן/.test(recent)) section = 'פתיחה';
|
||||
else if (/רקע|העובדות|תכנית/.test(recent)) section = 'רקע';
|
||||
else if (/טענות העורר|טוען העורר|המערער/.test(recent)) section = 'טענות עורר';
|
||||
else if (/טענות המשיב|טוענת המשיבה|טוענים המשיבים/.test(recent)) section = 'טענות משיב';
|
||||
else if (/דיון|הכרעה|לאחר שבחנו|נחדד|נזכיר/.test(recent)) section = 'דיון והכרעה';
|
||||
else if (/סוף דבר|לאור כל האמור|הערר נדחה|הערר מתקבל|ניתנה פה/.test(recent)) section = 'סוף דבר';
|
||||
document.getElementById('composeSectionGuess').textContent = section;
|
||||
}
|
||||
|
||||
function copyComposeText() {
|
||||
const ta = document.getElementById('composeTextarea');
|
||||
const title = document.getElementById('composeTitle').value;
|
||||
const text = (title ? title + '\n\n' : '') + ta.value;
|
||||
navigator.clipboard.writeText(text).then(() => toast('הועתק ללוח', 'success'));
|
||||
}
|
||||
|
||||
function saveComposeDraft() {
|
||||
try {
|
||||
localStorage.setItem('composeDraft', document.getElementById('composeTextarea').value);
|
||||
localStorage.setItem('composeTitle', document.getElementById('composeTitle').value);
|
||||
toast('הטיוטה נשמרה', 'success');
|
||||
} catch (e) {
|
||||
toast('שגיאה בשמירה', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Diagnostics Page ─────────────────────────────────
|
||||
|
||||
async function loadDiagnostics() {
|
||||
@@ -2783,22 +3515,31 @@ async function loadDiagnostics() {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Processing Visibility Panel ──────────────────────
|
||||
// ── Processing Visibility Panel (SSE push) ───────────
|
||||
|
||||
let _processPollTimer = null;
|
||||
let _tasksEventSource = null;
|
||||
|
||||
function startProcessPanelPolling() {
|
||||
if (_processPollTimer) return;
|
||||
pollProcessPanel();
|
||||
_processPollTimer = setInterval(pollProcessPanel, 3000);
|
||||
}
|
||||
|
||||
async function pollProcessPanel() {
|
||||
if (_tasksEventSource) return;
|
||||
try {
|
||||
const res = await fetch(API + '/system/tasks');
|
||||
const data = await res.json();
|
||||
renderProcessPanel(data.active || []);
|
||||
} catch (e) {}
|
||||
const es = new EventSource(API + '/system/tasks/stream');
|
||||
es.addEventListener('tasks', (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
renderProcessPanel(data.active || []);
|
||||
} catch (err) {}
|
||||
});
|
||||
es.onerror = () => {
|
||||
// Reconnect after a delay
|
||||
es.close();
|
||||
_tasksEventSource = null;
|
||||
setTimeout(startProcessPanelPolling, 5000);
|
||||
};
|
||||
_tasksEventSource = es;
|
||||
} catch (e) {
|
||||
// Browsers without EventSource fall back to one-shot fetch
|
||||
fetch(API + '/system/tasks').then(r => r.json()).then(d => renderProcessPanel(d.active || [])).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const STEP_LABELS = {
|
||||
|
||||
Reference in New Issue
Block a user