{% extends "base.twig" %}
{% block title %}{{ t('ui.interests.admin.page_title', {}, 'common') }}{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2"><i class="fas fa-layer-group me-2"></i>{{ t('ui.interests.admin.page_title', {}, 'common') }}</h1>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" onclick="showWizard()">
<i class="fas fa-wand-magic-sparkles me-1"></i>Generate Suggestions
</button>
<button type="button" class="btn btn-primary" onclick="showCreateModal()">
<i class="fas fa-plus me-1"></i>{{ t('ui.interests.admin.create', {}, 'common') }}
</button>
</div>
</div>
<div id="alertArea"></div>
<div class="card shadow">
<div class="card-body p-0">
<table class="table table-hover mb-0" id="interestsTable">
<thead class="table-dark">
<tr>
<th style="width:40px"></th>
<th>{{ t('ui.interests.admin.name_label', {}, 'common') }}</th>
<th>{{ t('ui.interests.admin.slug_label', {}, 'common') }}</th>
<th>{{ t('ui.interests.admin.echo_areas', {}, 'common') }}</th>
<th>{{ t('ui.interests.admin.file_areas', {}, 'common') }}</th>
<th>Subscribers</th>
<th>{{ t('ui.interests.admin.is_active_label', {}, 'common') }}</th>
<th></th>
</tr>
</thead>
<tbody id="interestsTableBody">
</tbody>
</table>
</div>
</div>
</div>
<!-- Unassigned Echo Areas Report -->
<div class="container-fluid mt-4">
<div class="card shadow">
<div class="card-header d-flex justify-content-between align-items-center" style="cursor:pointer" onclick="toggleUnassigned()" id="unassignedHeader">
<strong><i class="fas fa-circle-exclamation me-2 text-warning"></i>Echo Areas Not Assigned to Any Interest <span class="badge bg-secondary ms-2" id="unassignedCount"></span></strong>
<i class="fas fa-chevron-down" id="unassignedChevron"></i>
</div>
<div class="collapse" id="unassignedPanel">
<div class="card-body p-0">
<div id="unassignedLoading" class="text-center py-3 text-muted">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>Loading?
</div>
<table class="table table-sm table-hover mb-0 d-none" id="unassignedTable">
<thead class="table-light">
<tr>
<th>Tag</th>
<th>Description</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody id="unassignedTableBody"></tbody>
</table>
<div id="unassignedEmpty" class="text-muted text-center py-3 d-none">All echo areas are assigned to at least one interest.</div>
</div>
</div>
</div>
</div>
<!-- =========================================================
Create / Edit Modal
========================================================= -->
<div class="modal fade" id="interestModal" tabindex="-1" aria-labelledby="interestModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="interestModalLabel">{{ t('ui.interests.admin.create', {}, 'common') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<form id="interestForm">
<input type="hidden" id="interestId">
<div class="row g-3">
<div class="col-md-6">
<label for="interestName" class="form-label">{{ t('ui.interests.admin.name_label', {}, 'common') }} <span class="text-danger">*</span></label>
<input type="text" class="form-control" id="interestName" required maxlength="100">
</div>
<div class="col-md-6">
<label for="interestSlug" class="form-label">{{ t('ui.interests.admin.slug_label', {}, 'common') }}</label>
<input type="text" class="form-control" id="interestSlug" placeholder="auto-generated" maxlength="100">
<div class="form-text">Leave blank to auto-generate from name.</div>
</div>
<div class="col-12">
<label for="interestDescription" class="form-label">{{ t('ui.interests.admin.description_label', {}, 'common') }}</label>
<textarea class="form-control" id="interestDescription" rows="2"></textarea>
</div>
<div class="col-md-5">
<label for="interestIcon" class="form-label">{{ t('ui.interests.admin.icon_label', {}, 'common') }}</label>
<div class="input-group">
<span class="input-group-text"><i id="iconPreview" class="fas fa-layer-group"></i></span>
<input type="text" class="form-control" id="interestIcon" value="fa-layer-group" maxlength="50">
<button type="button" class="btn btn-outline-secondary" onclick="openIconPicker('interestIcon','iconPreview')" title="Browse icons">
<i class="fas fa-th"></i>
</button>
</div>
</div>
<div class="col-md-5">
<label class="form-label">{{ t('ui.interests.admin.color_label', {}, 'common') }}</label>
<div id="colorPalette" class="d-flex flex-wrap gap-1">
{# rendered by JS #}
</div>
<input type="hidden" id="interestColor" value="#6c757d">
</div>
<div class="col-md-1">
<label for="interestSortOrder" class="form-label">Order</label>
<input type="number" class="form-control" id="interestSortOrder" value="0" min="0">
</div>
<div class="col-md-1 d-flex align-items-end pb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="interestActive" checked>
<label class="form-check-label" for="interestActive">Active</label>
</div>
</div>
</div>
<!-- Echo Areas -->
<hr>
<h6>{{ t('ui.interests.admin.echo_areas', {}, 'common') }}</h6>
<input type="text" class="form-control mb-2" id="echoAreaSearch" placeholder="Search by tag or description?">
<div id="echoAreaList" style="max-height:220px;overflow-y:auto;border:1px solid var(--bs-border-color);border-radius:0.375rem;padding:0.5rem;">
<div class="text-muted small">Loading?</div>
</div>
<!-- File Areas -->
<hr>
<h6>{{ t('ui.interests.admin.file_areas', {}, 'common') }}</h6>
<input type="text" class="form-control mb-2" id="fileAreaSearch" placeholder="Search by tag or description?">
<div id="fileAreaList" style="max-height:180px;overflow-y:auto;border:1px solid var(--bs-border-color);border-radius:0.375rem;padding:0.5rem;">
<div class="text-muted small">Loading?</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveInterest()">Save</button>
</div>
</div>
</div>
</div>
<!-- =========================================================
Generation Wizard Modal
========================================================= -->
<div class="modal fade" id="wizardModal" tabindex="-1" aria-labelledby="wizardModalLabel" aria-hidden="true" data-bs-backdrop="static">
<div class="modal-dialog modal-xl modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="wizardModalLabel"><i class="fas fa-wand-magic-sparkles me-2"></i>Generate Interest Suggestions</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" id="wizardCloseBtn"></button>
</div>
<div class="modal-body" id="wizardBody">
<!-- Step 1: Options -->
<div id="wizardStep1">
<p class="text-muted">Analyse your echo areas and suggest Interest categories automatically. You can review, edit, and discard suggestions before saving.</p>
<div class="alert alert-info mb-3">
<strong><i class="fas fa-circle-info me-1"></i>Before you run the wizard</strong>
<ul class="mb-0 mt-1">
<li>Echo areas should be <strong>active</strong> and have <strong>descriptive names or descriptions</strong> filled in. Areas without descriptions are classified on tag name alone, which increases false positives ? especially with AI mode.</li>
<li>You can add descriptions on the <a href="/admin/subscriptions" class="alert-link">Echo Areas page</a> before continuing.</li>
<li>Review and discard any suggestions that don't look right before saving. You can manually add those areas to a relevant interest afterwards.</li>
</ul>
</div>
<div id="wizardWarning" class="alert alert-warning d-none">
<i class="fas fa-exclamation-triangle me-1"></i>
<span id="wizardWarningText"></span>
<a href="/admin/subscriptions" class="alert-link ms-1">Fill in descriptions on the Echo Areas page</a> for better results.
</div>
<div class="form-check mb-2" id="aiCheckRow">
<input class="form-check-input" type="checkbox" id="useAiCheck"
{{ ai_available ? 'checked' : 'disabled' }}>
<label class="form-check-label{% if not ai_available %} text-muted{% endif %}" for="useAiCheck">
<strong>Use AI classification</strong> for areas not matched by keywords
</label>
{% if not ai_available %}
<div class="form-text text-warning">
<i class="fas fa-key me-1"></i>Set <code>OPENAI_API_KEY</code> or <code>ANTHROPIC_API_KEY</code> in your <code>.env</code> file to enable AI classification.
</div>
{% endif %}
</div>
<div class="form-check mb-3" id="keywordCheckRow">
<input class="form-check-input" type="checkbox" id="useKeywordsCheck" checked>
<label class="form-check-label" for="useKeywordsCheck">
<strong>Use keyword pre-classification</strong> ? uncheck to let AI classify everything
</label>
</div>
</div>
<!-- Step 2: Running -->
<div id="wizardStep2" class="d-none text-center py-5">
<div class="spinner-border text-primary mb-3" style="width:3rem;height:3rem;" role="status"></div>
<div class="h5">Running classification?</div>
<div class="text-muted small" id="wizardRunNote"></div>
</div>
<!-- Step 3: Preview -->
<div id="wizardStep3" class="d-none">
<div id="wizardStats" class="mb-3 text-muted small"></div>
<div id="suggestionCards"></div>
</div>
<!-- Step 4: Done -->
<div id="wizardStep4" class="d-none text-center py-4">
<i class="fas fa-check-circle text-success" style="font-size:3rem;"></i>
<div class="h5 mt-3" id="wizardDoneMsg"></div>
</div>
</div>
<div class="modal-footer" id="wizardFooter">
<!-- Step 1 footer -->
<div id="wizardFooter1" class="w-100 d-flex justify-content-end gap-2">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="runGeneration()">
<i class="fas fa-play me-1"></i>Generate
</button>
</div>
<!-- Step 3 footer -->
<div id="wizardFooter3" class="w-100 d-flex justify-content-between align-items-center d-none">
<span class="text-muted small" id="wizardSaveCount"></span>
<div class="d-flex gap-2">
<button type="button" class="btn btn-secondary" onclick="resetWizard()">Back</button>
<button type="button" class="btn btn-success" onclick="saveAllSuggestions()" id="wizardSaveBtn">
<i class="fas fa-save me-1"></i>Save Interests
</button>
</div>
</div>
<!-- Step 4 footer -->
<div id="wizardFooter4" class="w-100 d-flex justify-content-end d-none">
<button type="button" class="btn btn-primary" data-bs-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
</div>
<style>
.color-swatch {
width: 26px; height: 26px;
border-radius: 50%;
cursor: pointer;
border: 2px solid transparent;
transition: transform .1s, border-color .1s;
flex-shrink: 0;
}
.color-swatch:hover { transform: scale(1.15); }
.color-swatch.selected { border-color: var(--bs-body-color); transform: scale(1.15); }
.suggestion-card { transition: opacity .2s; }
.suggestion-card.discarded { opacity: .35; pointer-events: none; }
.echo-pill { display:inline-flex; align-items:center; gap:4px; margin:2px; }
.echo-pill .remove-pill {
background:none; border:none; padding:0; line-height:1;
cursor:pointer; font-size:.7rem; opacity:.7;
}
.echo-pill .remove-pill:hover { opacity:1; }
#iconPickerGrid { display:grid; grid-template-columns:repeat(auto-fill,minmax(38px,1fr)); gap:4px; }
.icon-pick-btn {
width:38px; height:38px; padding:0;
display:flex; align-items:center; justify-content:center;
font-size:1.1rem;
}
</style>
<!-- =========================================================
Classify Echo Area Modal
========================================================= -->
<div class="modal fade" id="classifyModal" tabindex="-1" aria-labelledby="classifyModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="classifyModalLabel"><i class="fas fa-tag me-2"></i>Classify Echo Area</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<code id="classifyTag" class="fs-6"></code>
<div class="text-muted small mt-1" id="classifyDesc"></div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold small text-uppercase text-muted">Keyword Classification</label>
<div id="classifyKwStatus">
<span class="spinner-border spinner-border-sm me-2" role="status"></span>Classifying?
</div>
</div>
<div class="mb-3" id="classifyAiSection">
<label class="form-label fw-semibold small text-uppercase text-muted">AI Classification</label>
<div class="d-flex align-items-center gap-2 flex-wrap">
<button class="btn btn-sm btn-outline-secondary" id="classifyAiBtn" onclick="runAiClassify()">
<i class="fas fa-wand-magic-sparkles me-1"></i>Classify with AI
</button>
<span id="classifyAiStatus"></span>
</div>
</div>
<div class="mb-1">
<label for="classifyInterestSelect" class="form-label fw-semibold small text-uppercase text-muted">Assign to Interest</label>
<select class="form-select" id="classifyInterestSelect">
<option value="">? Select an interest ?</option>
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="classifyAssignBtn" onclick="assignToInterest()">
<i class="fas fa-check me-1"></i>Assign
</button>
</div>
</div>
</div>
</div>
<!-- =========================================================
Icon Picker Modal
========================================================= -->
<div class="modal fade" id="iconPickerModal" tabindex="-1" aria-labelledby="iconPickerModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header py-2">
<h6 class="modal-title" id="iconPickerModalLabel"><i class="fas fa-icons me-2"></i>Choose an Icon</h6>
<button type="button" class="btn-close btn-sm" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body pb-2">
<input type="text" class="form-control form-control-sm mb-2" id="iconSearch"
placeholder="Search icons?" oninput="filterIcons(this.value)">
<div id="iconPickerGrid" style="max-height:340px;overflow-y:auto;"></div>
</div>
</div>
</div>
</div>
<script>
const PALETTE = [
'#e74c3c','#c0392b','#e67e22','#f39c12','#f1c40f',
'#2ecc71','#27ae60','#1abc9c','#16a085',
'#3498db','#2980b9','#0078d4',
'#9b59b6','#8e44ad','#6c3483',
'#e91e63','#d35400','#a0522d',
'#795548','#8d6e63','#607d8b','#7f8c8d','#6c757d','#95a5a6',
'#2c3e50',
];
let allEchoareas = [];
let allFileareas = [];
// Persistent selection sets ? survive list re-renders caused by search filtering.
let selectedEchoIds = new Set();
let selectedFileIds = new Set();
let allInterests = [];
let wizardSuggestions = []; // current preview suggestions (may be edited/discarded)
document.addEventListener('DOMContentLoaded', () => {
loadInterests();
loadEchoareas();
loadFileareas();
loadUnassignedEchoareas();
toggleUnassigned(); // expanded by default
buildColorPalette('colorPalette', 'interestColor', '#6c757d');
document.getElementById('interestIcon').addEventListener('input', function() {
document.getElementById('iconPreview').className = 'fas ' + this.value.trim();
});
document.getElementById('echoAreaSearch').addEventListener('input', function() {
renderEchoareaList(this.value.toLowerCase());
});
document.getElementById('fileAreaSearch').addEventListener('input', function() {
renderFileareaList(this.value.toLowerCase());
});
});
// ---- Color palette -------------------------------------------------------
function buildColorPalette(containerId, hiddenId, initialColor) {
const container = document.getElementById(containerId);
container.innerHTML = '';
PALETTE.forEach(hex => {
const sw = document.createElement('div');
sw.className = 'color-swatch' + (hex === initialColor ? ' selected' : '');
sw.style.background = hex;
sw.title = hex;
sw.addEventListener('click', () => selectColor(containerId, hiddenId, hex));
container.appendChild(sw);
});
}
function selectColor(containerId, hiddenId, hex) {
document.getElementById(hiddenId).value = hex;
document.querySelectorAll(`#${containerId} .color-swatch`).forEach(sw => {
sw.classList.toggle('selected', sw.title === hex);
});
}
function setSelectedColor(containerId, hiddenId, hex) {
document.getElementById(hiddenId).value = hex;
document.querySelectorAll(`#${containerId} .color-swatch`).forEach(sw => {
sw.classList.toggle('selected', sw.title === hex);
});
}
// ---- Unassigned echo areas report ----------------------------------------
let unassignedData = null;
function toggleUnassigned() {
const panel = document.getElementById('unassignedPanel');
const chevron = document.getElementById('unassignedChevron');
const isOpen = panel.classList.contains('show');
if (isOpen) {
bootstrap.Collapse.getInstance(panel)?.hide();
chevron.classList.replace('fa-chevron-up', 'fa-chevron-down');
} else {
bootstrap.Collapse.getOrCreateInstance(panel).show();
chevron.classList.replace('fa-chevron-down', 'fa-chevron-up');
renderUnassignedPanel();
}
}
function renderUnassignedPanel() {
if (!unassignedData) return; // still loading; will be called again once data arrives
const loading = document.getElementById('unassignedLoading');
const table = document.getElementById('unassignedTable');
const empty = document.getElementById('unassignedEmpty');
loading.classList.add('d-none');
if (!unassignedData.areas || unassignedData.areas.length === 0) {
empty.classList.remove('d-none');
return;
}
const tbody = document.getElementById('unassignedTableBody');
tbody.innerHTML = unassignedData.areas.map(a => {
const echoUrl = '/echomail?echoarea=' + encodeURIComponent(a.tag) + (a.domain ? '&domain=' + encodeURIComponent(a.domain) : '');
return `<tr class="${a.is_active ? '' : 'text-muted'}">
<td><a href="${echoUrl}" target="_blank" class="font-monospace text-decoration-none">${escapeHtml(a.tag)}<i class="fas fa-external-link-alt ms-1 small opacity-50"></i></a></td>
<td>${escapeHtml(a.description || '')}</td>
<td>${a.is_active ? '<span class="badge bg-success">Active</span>' : '<span class="badge bg-secondary">Inactive</span>'}</td>
<td class="text-end"><button class="btn btn-sm btn-outline-primary" onclick="openClassifyModal(${a.id})"><i class="fas fa-tag me-1"></i>Classify</button></td>
</tr>`;
}).join('');
table.classList.remove('d-none');
}
function refreshUnassigned() {
// Reset panel state so it re-renders after reload
unassignedData = null;
document.getElementById('unassignedLoading').classList.remove('d-none');
document.getElementById('unassignedTable').classList.add('d-none');
document.getElementById('unassignedEmpty').classList.add('d-none');
loadUnassignedEchoareas();
}
function loadUnassignedEchoareas() {
fetch('/api/admin/interests/unassigned-echoareas')
.then(r => r.json())
.then(data => {
unassignedData = data;
const badge = document.getElementById('unassignedCount');
if (badge) badge.textContent = data.count ?? 0;
// Always render ? the panel may still be animating open (Bootstrap uses
// 'collapsing' during transition, only adding 'show' when done), so
// checking for 'show' misses fast responses and leaves the spinner stuck.
renderUnassignedPanel();
})
.catch(() => {
document.getElementById('unassignedLoading').innerHTML = '<span class="text-danger">Failed to load.</span>';
});
}
function escapeHtml(str) {
if (!str) return '';
return String(str).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
// ---- Interests table -----------------------------------------------------
function loadInterests() {
fetch('/api/admin/interests')
.then(r => r.json())
.then(data => {
allInterests = Array.isArray(data) ? data : [];
renderInterestsTable(data);
})
.catch(() => showAlert('Failed to load interests.', 'danger'));
}
function loadEchoareas() {
fetch('/api/echoareas/simple-list')
.then(r => r.json())
.then(data => { allEchoareas = Array.isArray(data) ? data : (data.echoareas || []); });
}
function loadFileareas() {
fetch('/api/fileareas')
.then(r => r.json())
.then(data => { allFileareas = Array.isArray(data) ? data : (data.fileareas || []); });
}
function renderInterestsTable(interests) {
const tbody = document.getElementById('interestsTableBody');
if (!interests.length) {
tbody.innerHTML = '<tr><td colspan="8" class="text-center text-muted py-4">No interests defined. Use "Generate Suggestions" to get started.</td></tr>';
return;
}
tbody.innerHTML = interests.map(i => `
<tr>
<td>
<span style="display:inline-block;width:18px;height:18px;border-radius:50%;background:${escHtml(i.color)};vertical-align:middle;"></span>
</td>
<td><i class="fas ${escHtml(i.icon)} me-1" style="color:${escHtml(i.color)}"></i>${escHtml(i.name)}</td>
<td><code>${escHtml(i.slug)}</code></td>
<td>${i.echoarea_count ?? 0}</td>
<td>${i.filearea_count ?? 0}</td>
<td>${i.subscriber_count ?? 0}</td>
<td>${i.is_active ? '<span class="badge bg-success">Yes</span>' : '<span class="badge bg-secondary">No</span>'}</td>
<td class="text-end">
<button class="btn btn-sm btn-outline-secondary me-1" onclick="editInterest(${i.id})"><i class="fas fa-edit"></i></button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteInterest(${i.id})"><i class="fas fa-trash"></i></button>
</td>
</tr>
`).join('');
}
// ---- Create / Edit modal -------------------------------------------------
/** Group an array of echo area objects by domain, return ordered Map(networkLabel ? areas[]) */
function groupEchoareasByNetwork(areas) {
const groups = new Map();
for (const e of areas) {
const net = (e.domain && e.domain.trim()) ? e.domain.trim().toUpperCase() : 'Local';
if (!groups.has(net)) groups.set(net, []);
groups.get(net).push(e);
}
// Sort network names alphabetically, but keep "Local" last
const sorted = new Map([...groups.entries()].sort((a, b) => {
if (a[0] === 'Local') return 1;
if (b[0] === 'Local') return -1;
return a[0].localeCompare(b[0]);
}));
return sorted;
}
/** Render a grouped echo area checklist into container; uses selectedEchoIds set. */
function renderEchoareasByNetwork(container, areas, idPrefix, cssClass, selectionSet) {
if (!areas.length) { container.innerHTML = '<div class="text-muted small">No matching echo areas.</div>'; return; }
const groups = groupEchoareasByNetwork(areas);
let html = '';
for (const [net, netAreas] of groups) {
html += `<p class="mb-1 mt-2 fw-semibold small text-uppercase text-muted" style="letter-spacing:.05em">${escHtml(net)}</p>`;
html += netAreas.map(e => {
const checked = selectionSet.has(e.id) ? 'checked' : '';
return `<div class="form-check">
<input class="form-check-input ${cssClass}" type="checkbox" value="${e.id}" id="${idPrefix}${e.id}" ${checked}>
<label class="form-check-label small" for="${idPrefix}${e.id}">
<strong>${escHtml(e.tag)}</strong>${e.description ? ' ? ' + escHtml(e.description) : ''}
</label>
</div>`;
}).join('');
}
container.innerHTML = html;
// Sync checkbox changes back to the persistent set
container.querySelectorAll(`.${cssClass}`).forEach(cb => {
cb.addEventListener('change', () => {
const id = parseInt(cb.value);
if (cb.checked) selectionSet.add(id); else selectionSet.delete(id);
});
});
}
function renderEchoareaList(filter = '') {
const container = document.getElementById('echoAreaList');
const filtered = allEchoareas.filter(e =>
!filter || (e.tag||'').toLowerCase().includes(filter) || (e.description||'').toLowerCase().includes(filter)
);
renderEchoareasByNetwork(container, filtered, 'ea_', 'echoArea-check', selectedEchoIds);
}
function renderFileareaList(filter = '') {
const container = document.getElementById('fileAreaList');
const filtered = allFileareas.filter(f =>
!filter || (f.tag||'').toLowerCase().includes(filter) || (f.description||'').toLowerCase().includes(filter)
);
if (!filtered.length) { container.innerHTML = '<div class="text-muted small">No matching file areas.</div>'; return; }
container.innerHTML = filtered.map(f => {
const checked = selectedFileIds.has(f.id) ? 'checked' : '';
return `<div class="form-check">
<input class="form-check-input fileArea-check" type="checkbox" value="${f.id}" id="fa_${f.id}" ${checked}>
<label class="form-check-label small" for="fa_${f.id}">
<strong>${escHtml(f.tag)}</strong>${f.description ? ' ? ' + escHtml(f.description) : ''}
</label>
</div>`;
}).join('');
container.querySelectorAll('.fileArea-check').forEach(cb => {
cb.addEventListener('change', () => {
const id = parseInt(cb.value);
if (cb.checked) selectedFileIds.add(id); else selectedFileIds.delete(id);
});
});
}
function renderEchoareaListWithSelected(ids) {
selectedEchoIds = new Set(ids);
renderEchoareaList(document.getElementById('echoAreaSearch').value.toLowerCase());
}
function renderFileareaListWithSelected(ids) {
selectedFileIds = new Set(ids);
renderFileareaList(document.getElementById('fileAreaSearch').value.toLowerCase());
}
function getCheckedIds(prefix) {
if (prefix === 'echoArea') return Array.from(selectedEchoIds);
if (prefix === 'fileArea') return Array.from(selectedFileIds);
return Array.from(document.querySelectorAll(`.${prefix}-check:checked`)).map(el => parseInt(el.value));
}
function showCreateModal() {
document.getElementById('interestId').value = '';
document.getElementById('interestModalLabel').textContent = '{{ t('ui.interests.admin.create', {}, 'common') }}';
document.getElementById('interestForm').reset();
document.getElementById('interestIcon').value = 'fa-layer-group';
document.getElementById('iconPreview').className = 'fas fa-layer-group';
document.getElementById('echoAreaSearch').value = '';
document.getElementById('fileAreaSearch').value = '';
selectedEchoIds = new Set();
selectedFileIds = new Set();
buildColorPalette('colorPalette', 'interestColor', '#6c757d');
renderEchoareaList();
renderFileareaList();
new bootstrap.Modal(document.getElementById('interestModal')).show();
}
function editInterest(id) {
new bootstrap.Modal(document.getElementById('interestModal')).show();
fetch(`/api/admin/interests/${id}`)
.then(r => r.json())
.then(interest => {
document.getElementById('interestId').value = interest.id;
document.getElementById('interestModalLabel').textContent = '{{ t('ui.interests.admin.edit', {}, 'common') }}';
document.getElementById('interestName').value = interest.name;
document.getElementById('interestSlug').value = interest.slug;
document.getElementById('interestDescription').value = interest.description || '';
document.getElementById('interestIcon').value = interest.icon || 'fa-layer-group';
document.getElementById('iconPreview').className = 'fas ' + (interest.icon || 'fa-layer-group');
document.getElementById('interestSortOrder').value = interest.sort_order || 0;
document.getElementById('interestActive').checked = !!interest.is_active;
document.getElementById('echoAreaSearch').value = '';
document.getElementById('fileAreaSearch').value = '';
buildColorPalette('colorPalette', 'interestColor', interest.color || '#6c757d');
const echoIds = (interest.echoareas || []).map(e => e.id);
const fileIds = (interest.fileareas || []).map(f => f.id);
renderEchoareaListWithSelected(echoIds);
renderFileareaListWithSelected(fileIds);
})
.catch(() => { renderEchoareaList(); renderFileareaList(); });
}
function saveInterest() {
const id = document.getElementById('interestId').value;
const name = document.getElementById('interestName').value.trim();
if (!name) { showAlert('Name is required.', 'warning'); return; }
const payload = {
name,
slug: document.getElementById('interestSlug').value.trim() || null,
description: document.getElementById('interestDescription').value.trim(),
icon: document.getElementById('interestIcon').value.trim() || 'fa-layer-group',
color: document.getElementById('interestColor').value,
sort_order: parseInt(document.getElementById('interestSortOrder').value) || 0,
is_active: document.getElementById('interestActive').checked,
};
const echoIds = getCheckedIds('echoArea');
const fileIds = getCheckedIds('fileArea');
const isEdit = !!id;
const url = isEdit ? `/api/admin/interests/${id}` : '/api/admin/interests';
const method = isEdit ? 'PUT' : 'POST';
fetch(url, { method, headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) })
.then(r => r.json())
.then(data => {
if (!data.success) { showAlert(data.error || 'Failed to save.', 'danger'); return; }
const interestId = isEdit ? parseInt(id) : data.id;
return Promise.all([
fetch(`/api/admin/interests/${interestId}/echoareas`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ids: echoIds})
}),
fetch(`/api/admin/interests/${interestId}/fileareas`, {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ids: fileIds})
}),
]);
})
.then(() => {
bootstrap.Modal.getInstance(document.getElementById('interestModal')).hide();
showAlert('{{ t('ui.interests.admin.saved_success', {}, 'common') }}', 'success');
loadInterests();
refreshUnassigned();
})
.catch(() => showAlert('An error occurred.', 'danger'));
}
function deleteInterest(id) {
if (!confirm('{{ t('ui.interests.admin.delete_confirm', {}, 'common') }}')) return;
fetch(`/api/admin/interests/${id}`, { method: 'DELETE' })
.then(r => r.json())
.then(data => {
if (data.success) {
showAlert('{{ t('ui.interests.admin.deleted_success', {}, 'common') }}', 'success');
loadInterests();
refreshUnassigned();
} else {
showAlert(data.error || 'Delete failed.', 'danger');
}
})
.catch(() => showAlert('An error occurred.', 'danger'));
}
// ---- Generation wizard ---------------------------------------------------
function showWizard() {
resetWizard();
new bootstrap.Modal(document.getElementById('wizardModal')).show();
}
function resetWizard() {
showWizardStep(1);
}
function showWizardStep(n) {
[1,2,3,4].forEach(i => {
document.getElementById(`wizardStep${i}`).classList.toggle('d-none', i !== n);
});
document.getElementById('wizardFooter1').classList.toggle('d-none', n !== 1);
document.getElementById('wizardFooter3').classList.toggle('d-none', n !== 3);
document.getElementById('wizardFooter4').classList.toggle('d-none', n !== 4);
document.getElementById('wizardCloseBtn').disabled = (n === 2);
}
function runGeneration() {
showWizardStep(2);
const useAi = document.getElementById('useAiCheck').checked;
const useKeywords = document.getElementById('useKeywordsCheck').checked;
if (!useKeywords && useAi) {
document.getElementById('wizardRunNote').textContent = 'Sending all areas to Claude Haiku for classification?';
} else if (useAi) {
document.getElementById('wizardRunNote').textContent = 'Running keyword pass, then sending unmatched areas to Claude Haiku?';
} else {
document.getElementById('wizardRunNote').textContent = 'Running keyword classification?';
}
fetch('/api/admin/interests/generate', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({use_ai: useAi, use_keywords: useKeywords})
})
.then(r => r.json())
.then(result => {
if (result.error) { throw new Error(result.error); }
showPreview(result);
})
.catch(err => {
showWizardStep(1);
showAlert('Generation failed: ' + err.message, 'danger');
});
}
function showPreview(result) {
const { suggestions, stats } = result;
wizardSuggestions = suggestions.map((s, idx) => ({...s, _idx: idx, _discarded: false}));
// Warning for no-description areas
const warn = document.getElementById('wizardWarning');
if (stats.no_description > 0) {
document.getElementById('wizardWarningText').textContent =
`${stats.no_description} echo area(s) have no description ? classification accuracy is lower for these.`;
warn.classList.remove('d-none');
} else {
warn.classList.add('d-none');
}
// Stats bar
const aiNote = stats.ai_available ? '' : ' (AI unavailable ? keyword only)';
document.getElementById('wizardStats').innerHTML =
`<strong>${stats.categorised}</strong> of <strong>${stats.total}</strong> echo areas categorised into ` +
`<strong>${suggestions.length}</strong> suggested interests${aiNote}. ` +
`<span class="text-muted">${stats.uncategorised} uncategorised.</span>`;
renderSuggestionCards();
showWizardStep(3);
updateSaveCount();
}
/** Initialise Bootstrap tooltips on all pill elements inside a container. */
function initPillTooltips(container) {
container.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(el => {
new bootstrap.Tooltip(el, {placement: 'top', trigger: 'hover'});
});
}
/** Build grouped pills HTML for a suggestion's echo area list. */
function buildPillsHtml(echoareas, idx) {
if (!echoareas.length) return '<span class="text-muted small">None</span>';
const groups = groupEchoareasByNetwork(echoareas);
let html = '';
for (const [net, netAreas] of groups) {
html += `<span class="d-block fw-semibold small text-uppercase text-muted mb-1" style="letter-spacing:.05em">${escHtml(net)}</span>`;
html += netAreas.map(ea => {
const tip = ea.description ? ` data-bs-toggle="tooltip" data-bs-title="${escAttr(ea.description)}"` : '';
return `<span class="echo-pill badge bg-secondary fw-normal"${tip}>
${escHtml(ea.tag)}
<button type="button" class="remove-pill" onclick="removeAreaFromSuggestion(${idx},${ea.id})" title="Remove">?</button>
</span>`;
}).join('');
html += '<br>';
}
return html;
}
function renderSuggestionCards() {
const container = document.getElementById('suggestionCards');
if (!wizardSuggestions.length) {
container.innerHTML = '<div class="alert alert-info">No new interest suggestions found. All categories may already exist.</div>';
return;
}
container.innerHTML = wizardSuggestions.map((s, idx) => {
const palId = `pal_${idx}`;
const hidId = `hidColor_${idx}`;
const icnId = `icnInput_${idx}`;
const icnPrev = `icnPrev_${idx}`;
const sourceClass = s.source === 'ai' ? 'bg-info-subtle text-info' : 'bg-secondary-subtle text-secondary';
const pillsHtml = buildPillsHtml(s.echoareas, idx);
return `<div class="card mb-3 suggestion-card" id="sugCard_${idx}">
<div class="card-header d-flex align-items-center gap-2 py-2">
<i class="fas ${escHtml(s.icon)}" id="${icnPrev}" style="color:${escHtml(s.color)};font-size:1.1rem;"></i>
<input type="text" class="form-control form-control-sm fw-bold"
id="sugName_${idx}" value="${escHtml(s.name)}"
style="max-width:280px;font-size:1rem;" oninput="updateSuggestionName(${idx},this.value)">
<span class="badge ${sourceClass} ms-1">${escHtml(s.source)}</span>
<div class="ms-auto d-flex align-items-center gap-2">
<button type="button" class="btn btn-sm btn-outline-danger" onclick="discardSuggestion(${idx})">
<i class="fas fa-times me-1"></i>Discard
</button>
</div>
</div>
<div class="card-body py-2">
<div class="row g-2 align-items-center mb-2">
<div class="col-auto">
<label class="form-label small mb-0">Icon</label>
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="fas ${escHtml(s.icon)}" id="icnPrevBig_${idx}"></i></span>
<input type="text" class="form-control" id="${icnId}" value="${escHtml(s.icon)}" maxlength="50"
oninput="updateSuggestionIcon(${idx},this.value)" style="max-width:130px;">
<button type="button" class="btn btn-outline-secondary btn-sm"
onclick="openIconPicker('${icnId}','icnPrevBig_${idx}')" title="Browse icons">
<i class="fas fa-th"></i>
</button>
</div>
</div>
<div class="col-auto">
<label class="form-label small mb-0">Color</label>
<div id="${palId}" class="d-flex flex-wrap gap-1" style="max-width:320px;">
</div>
<input type="hidden" id="${hidId}" value="${escHtml(s.color)}">
</div>
</div>
<div class="mb-1">
<strong class="small">Echo Areas (${s.echoareas.length}):</strong>
<div id="pills_${idx}" class="mt-1">${pillsHtml}</div>
</div>
<div class="mt-2" id="addAreaRow_${idx}">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="toggleAddArea(${idx})">
<i class="fas fa-plus me-1"></i>Add echo areas
</button>
<div id="addAreaPanel_${idx}" class="d-none mt-2">
<input type="text" class="form-control form-control-sm mb-1"
id="addAreaSearch_${idx}" placeholder="Search?"
oninput="renderAddAreaList(${idx},this.value)">
<div id="addAreaList_${idx}" style="max-height:130px;overflow-y:auto;border:1px solid var(--bs-border-color);border-radius:.375rem;padding:.4rem;">
</div>
</div>
</div>
</div>
</div>`;
}).join('');
// Build colour palettes inside each card after rendering
wizardSuggestions.forEach((s, idx) => {
buildColorPalette(`pal_${idx}`, `hidColor_${idx}`, s.color);
// Wire color changes
document.getElementById(`pal_${idx}`).addEventListener('click', () => {
const hex = document.getElementById(`hidColor_${idx}`).value;
wizardSuggestions[idx].color = hex;
document.getElementById(`icnPrev_${idx}`) && (document.getElementById(`icnPrev_${idx}`).style.color = hex);
const bigPrev = document.getElementById(`icnPrevBig_${idx}`);
if (bigPrev) bigPrev.style.color = hex;
});
});
// Initialise tooltips on all pill badges
initPillTooltips(container);
}
function updateSuggestionName(idx, val) {
wizardSuggestions[idx].name = val;
}
function updateSuggestionIcon(idx, val) {
wizardSuggestions[idx].icon = val;
const el = document.getElementById(`icnPrevBig_${idx}`);
if (el) el.className = 'fas ' + val.trim();
}
function discardSuggestion(idx) {
wizardSuggestions[idx]._discarded = true;
document.getElementById(`sugCard_${idx}`).classList.add('discarded');
updateSaveCount();
}
function removeAreaFromSuggestion(idx, areaId) {
wizardSuggestions[idx].echoareas = wizardSuggestions[idx].echoareas.filter(ea => ea.id !== areaId);
// Re-render just this card's pills
const pillsDiv = document.getElementById(`pills_${idx}`);
if (pillsDiv) {
pillsDiv.innerHTML = buildPillsHtml(wizardSuggestions[idx].echoareas, idx);
initPillTooltips(pillsDiv);
}
// Update header count
const countEl = document.querySelector(`#sugCard_${idx} .card-body .small strong`);
if (countEl) countEl.textContent = `Echo Areas (${wizardSuggestions[idx].echoareas.length}):`;
}
function toggleAddArea(idx) {
const panel = document.getElementById(`addAreaPanel_${idx}`);
panel.classList.toggle('d-none');
if (!panel.classList.contains('d-none')) {
renderAddAreaList(idx, '');
}
}
function renderAddAreaList(idx, filter) {
const container = document.getElementById(`addAreaList_${idx}`);
const existing = new Set(wizardSuggestions[idx].echoareas.map(ea => ea.id));
const filtered = allEchoareas.filter(e =>
!existing.has(e.id) && (
!filter || (e.tag||'').toLowerCase().includes(filter.toLowerCase()) ||
(e.description||'').toLowerCase().includes(filter.toLowerCase())
)
);
if (!filtered.length) { container.innerHTML = '<div class="text-muted small p-1">No matching areas.</div>'; return; }
const groups = groupEchoareasByNetwork(filtered);
let html = '';
for (const [net, netAreas] of groups) {
html += `<p class="mb-1 mt-2 fw-semibold small text-uppercase text-muted" style="letter-spacing:.05em">${escHtml(net)}</p>`;
html += netAreas.map(e =>
`<div class="form-check py-0">
<input class="form-check-input" type="checkbox" id="addEa_${idx}_${e.id}" value="${e.id}"
onchange="toggleAddAreaCheck(${idx}, ${e.id}, '${escAttr(e.tag)}', '${escAttr(e.description||'')}', this.checked)">
<label class="form-check-label small" for="addEa_${idx}_${e.id}">
<strong>${escHtml(e.tag)}</strong>${e.description ? ' ? ' + escHtml(e.description) : ''}
</label>
</div>`
).join('');
}
container.innerHTML = html;
}
function toggleAddAreaCheck(idx, areaId, tag, description, checked) {
if (checked) {
wizardSuggestions[idx].echoareas.push({id: areaId, tag, description});
} else {
wizardSuggestions[idx].echoareas = wizardSuggestions[idx].echoareas.filter(ea => ea.id !== areaId);
}
// Re-render pills
const pillsDiv = document.getElementById(`pills_${idx}`);
if (pillsDiv) {
pillsDiv.innerHTML = buildPillsHtml(wizardSuggestions[idx].echoareas, idx);
initPillTooltips(pillsDiv);
}
}
function updateSaveCount() {
const active = wizardSuggestions.filter(s => !s._discarded);
document.getElementById('wizardSaveCount').textContent = `${active.length} interest(s) will be saved`;
document.getElementById('wizardSaveBtn').disabled = active.length === 0;
}
async function saveAllSuggestions() {
const toSave = wizardSuggestions.filter(s => !s._discarded && s.name.trim());
if (!toSave.length) return;
document.getElementById('wizardSaveBtn').disabled = true;
document.getElementById('wizardSaveBtn').innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Saving?';
let saved = 0;
let failed = 0;
for (const s of toSave) {
try {
const createRes = await fetch('/api/admin/interests', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({
name: s.name.trim(),
icon: s.icon || 'fa-layer-group',
color: document.getElementById(`hidColor_${s._idx}`)?.value || s.color,
})
});
const createData = await createRes.json();
if (!createData.success) { failed++; continue; }
const interestId = createData.id;
await fetch(`/api/admin/interests/${interestId}/echoareas`, {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({ids: s.echoareas.map(ea => ea.id)})
});
saved++;
} catch(e) {
failed++;
}
}
const msg = saved > 0
? `${saved} interest(s) created successfully.` + (failed > 0 ? ` ${failed} failed.` : '')
: `All ${failed} saves failed.`;
document.getElementById('wizardDoneMsg').textContent = msg;
showWizardStep(4);
loadInterests();
}
// ---- Classify Echo Area modal --------------------------------------------
let classifyEchoareaId = null;
function openClassifyModal(echoareaId) {
const area = (unassignedData?.areas || []).find(a => a.id === echoareaId);
if (!area) return;
classifyEchoareaId = echoareaId;
document.getElementById('classifyTag').textContent = area.tag;
document.getElementById('classifyDesc').textContent = area.description || '';
document.getElementById('classifyModalLabel').innerHTML =
'<i class="fas fa-tag me-2"></i>Classify: ' + escHtml(area.tag);
// Reset state
document.getElementById('classifyKwStatus').innerHTML =
'<span class="spinner-border spinner-border-sm me-2" role="status"></span>Classifying?';
document.getElementById('classifyAiStatus').innerHTML = '';
document.getElementById('classifyAiBtn').disabled = false;
document.getElementById('classifyAiSection').classList.remove('d-none');
document.getElementById('classifyAssignBtn').disabled = false;
document.getElementById('classifyAssignBtn').innerHTML = '<i class="fas fa-check me-1"></i>Assign';
// Populate interest selector
const select = document.getElementById('classifyInterestSelect');
select.innerHTML = '<option value="">? Select an interest ?</option>' +
allInterests.map(i => `<option value="${i.id}">${escHtml(i.name)}</option>`).join('');
bootstrap.Modal.getOrCreateInstance(document.getElementById('classifyModal')).show();
// Run keyword classification automatically
fetch(`/api/admin/interests/echoarea/${echoareaId}/classify`)
.then(r => r.json())
.then(data => {
renderKwResult(data);
if (!data.ai_available) {
document.getElementById('classifyAiSection').classList.add('d-none');
}
if (data.final_guess) {
autoSelectInterest(data.final_guess);
}
})
.catch(() => {
document.getElementById('classifyKwStatus').innerHTML =
'<span class="text-danger">Classification failed.</span>';
});
}
function renderKwResult(data) {
const el = document.getElementById('classifyKwStatus');
if (data.keyword_guess) {
const meta = data.category_meta || {};
el.innerHTML = `<span class="badge" style="background:${escHtml(meta.color || '#6c757d')}">
<i class="fas ${escHtml(meta.icon || 'fa-layer-group')} me-1"></i>${escHtml(data.keyword_guess)}
</span> <small class="text-muted">matched by keyword</small>`;
} else {
el.innerHTML = '<span class="text-muted fst-italic">No keyword match found</span>';
}
}
function autoSelectInterest(guessName) {
const select = document.getElementById('classifyInterestSelect');
const lower = guessName.toLowerCase();
for (const opt of select.options) {
if (opt.text.toLowerCase() === lower) {
opt.selected = true;
return;
}
}
}
function runAiClassify() {
const btn = document.getElementById('classifyAiBtn');
const status = document.getElementById('classifyAiStatus');
btn.disabled = true;
status.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>Asking AI?';
fetch(`/api/admin/interests/echoarea/${classifyEchoareaId}/classify-ai`, { method: 'POST' })
.then(r => r.json())
.then(data => {
if (data.ai_guess) {
const meta = data.category_meta || {};
status.innerHTML = `<span class="badge" style="background:${escHtml(meta.color || '#6c757d')}">
<i class="fas ${escHtml(meta.icon || 'fa-layer-group')} me-1"></i>${escHtml(data.ai_guess)}
</span> <small class="text-muted">AI suggestion</small>`;
autoSelectInterest(data.ai_guess);
} else {
status.innerHTML = '<span class="text-muted fst-italic">AI could not classify this area</span>';
}
})
.catch(() => {
status.innerHTML = '<span class="text-danger">AI classification failed.</span>';
btn.disabled = false;
});
}
function assignToInterest() {
const interestId = document.getElementById('classifyInterestSelect').value;
if (!interestId) {
document.getElementById('classifyInterestSelect').focus();
return;
}
const btn = document.getElementById('classifyAssignBtn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-1" role="status"></span>Assigning?';
fetch(`/api/admin/interests/${interestId}/echoareas/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ echoarea_id: classifyEchoareaId }),
})
.then(r => r.json())
.then(data => {
if (data.ok) {
bootstrap.Modal.getInstance(document.getElementById('classifyModal')).hide();
const savedScroll = window.scrollY;
refreshUnassigned();
loadInterests();
showAlert('Echo area assigned successfully.', 'success');
requestAnimationFrame(() => window.scrollTo({ top: savedScroll, behavior: 'instant' }));
} else {
showAlert('Failed to assign: ' + escHtml(data.error || 'Unknown error'), 'danger');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-check me-1"></i>Assign';
}
})
.catch(() => {
showAlert('Failed to assign echo area.', 'danger');
btn.disabled = false;
btn.innerHTML = '<i class="fas fa-check me-1"></i>Assign';
});
}
// ---- Utility -------------------------------------------------------------
function showAlert(msg, type) {
const area = document.getElementById('alertArea');
area.innerHTML = `<div class="alert alert-${type} alert-dismissible fade show" role="alert">
${escHtml(msg)}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>`;
area.scrollIntoView({behavior:'smooth',block:'nearest'});
}
function escHtml(str) {
if (str == null) return '';
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
.replace(/"/g,'"').replace(/'/g,''');
}
function escAttr(str) {
if (str == null) return '';
return String(str).replace(/'/g,"\\'").replace(/"/g,'"');
}
// ---- Icon picker ---------------------------------------------------------
const ICON_LIST = [
// Communication & messaging
'fa-comments','fa-comment','fa-envelope','fa-paper-plane','fa-broadcast-tower',
'fa-satellite-dish','fa-phone','fa-terminal','fa-server','fa-network-wired',
// Computing & tech
'fa-desktop','fa-laptop','fa-microchip','fa-cpu','fa-code','fa-bug',
'fa-database','fa-hdd','fa-keyboard','fa-mouse','fa-memory','fa-plug',
'fa-robot','fa-cogs','fa-tools','fa-wrench','fa-screwdriver','fa-hammer',
'fa-linux','fa-windows','fa-apple','fa-android',
// Science & space
'fa-rocket','fa-satellite','fa-globe','fa-flask','fa-atom','fa-dna',
'fa-microscope','fa-star','fa-moon','fa-sun','fa-cloud','fa-cloud-sun',
'fa-meteor','fa-temperature-high','fa-wind',
// Arts & entertainment
'fa-music','fa-headphones','fa-guitar','fa-film','fa-tv','fa-book',
'fa-book-open','fa-palette','fa-paint-brush','fa-pen','fa-pencil-alt',
'fa-camera','fa-photo-video','fa-images','fa-theater-masks',
'fa-gamepad','fa-dice','fa-chess','fa-puzzle-piece',
// People & community
'fa-users','fa-user-friends','fa-handshake','fa-hands-helping',
'fa-comments-dollar','fa-bullhorn','fa-megaphone','fa-vote-yea',
'fa-landmark','fa-flag','fa-globe-americas','fa-map-marker-alt',
// Nature & outdoors
'fa-tree','fa-leaf','fa-seedling','fa-mountain','fa-hiking',
'fa-campground','fa-fish','fa-paw','fa-dog','fa-cat','fa-crow',
// Food & drink
'fa-utensils','fa-coffee','fa-beer','fa-wine-glass','fa-pizza-slice',
'fa-hamburger','fa-apple-alt','fa-carrot',
// Health & body
'fa-heartbeat','fa-heart','fa-medkit','fa-hospital','fa-dumbbell',
'fa-running','fa-bicycle','fa-swimmer','fa-spa',
// Religion & philosophy
'fa-place-of-worship','fa-cross','fa-om','fa-star-of-david','fa-yin-yang',
'fa-praying-hands',
// Misc & admin
'fa-layer-group','fa-th-large','fa-th','fa-sitemap','fa-project-diagram',
'fa-shield-alt','fa-lock','fa-key','fa-eye','fa-search',
'fa-map','fa-compass','fa-clock','fa-calendar','fa-bell',
'fa-tag','fa-tags','fa-bookmark','fa-thumbtack','fa-paperclip',
'fa-archive','fa-box','fa-gift','fa-shopping-cart',
'fa-dollar-sign','fa-coins','fa-chart-bar','fa-chart-line',
'fa-ghost','fa-skull','fa-hat-wizard','fa-magic',
'fa-laugh','fa-smile','fa-meh','fa-frown',
'fa-question','fa-info-circle','fa-exclamation-circle',
'fa-home','fa-building','fa-city','fa-monument',
'fa-car','fa-train','fa-plane','fa-ship','fa-motorcycle',
'fa-broadcast-tower','fa-podcast','fa-rss',
'fa-ham','fa-sitemap','fa-broadcast-tower',
];
// deduplicate
const ICONS = [...new Set(ICON_LIST)];
let _iconTargetInput = null;
let _iconTargetPreview = null;
function openIconPicker(inputId, previewId) {
_iconTargetInput = inputId;
_iconTargetPreview = previewId;
document.getElementById('iconSearch').value = '';
renderIconGrid('');
// Mark current selection
const current = document.getElementById(inputId)?.value?.trim();
document.querySelectorAll('.icon-pick-btn').forEach(btn => {
btn.classList.toggle('selected', btn.dataset.icon === current);
});
new bootstrap.Modal(document.getElementById('iconPickerModal')).show();
}
function filterIcons(query) {
renderIconGrid(query.toLowerCase().trim());
}
function renderIconGrid(filter) {
const current = _iconTargetInput ? (document.getElementById(_iconTargetInput)?.value?.trim() ?? '') : '';
const filtered = filter ? ICONS.filter(ic => ic.replace('fa-','').includes(filter)) : ICONS;
const grid = document.getElementById('iconPickerGrid');
grid.innerHTML = filtered.map(ic =>
`<button type="button" class="icon-pick-btn btn btn-sm ${ic===current?'btn-primary':'btn-outline-secondary'}"
data-icon="${ic}" onclick="pickIcon('${ic}')" title="${ic.replace('fa-','')}">
<i class="fas ${ic}"></i>
</button>`
).join('');
}
function pickIcon(icon) {
if (_iconTargetInput) {
document.getElementById(_iconTargetInput).value = icon;
}
if (_iconTargetPreview) {
document.getElementById(_iconTargetPreview).className = 'fas ' + icon;
}
// Update selection highlight
document.querySelectorAll('.icon-pick-btn').forEach(btn => {
const active = btn.dataset.icon === icon;
btn.classList.toggle('btn-primary', active);
btn.classList.toggle('btn-outline-secondary', !active);
});
// Auto-close after a short delay so the user sees the selection
setTimeout(() => {
bootstrap.Modal.getInstance(document.getElementById('iconPickerModal'))?.hide();
}, 200);
}
</script>
{% endblock %}
|