376 lines
16 KiB
JavaScript
376 lines
16 KiB
JavaScript
/* ═══════════════════════════════════════════════════════
|
|
AWESOME LISTS // Propaganda Resource Controller
|
|
═══════════════════════════════════════════════════════ */
|
|
(function () {
|
|
'use strict';
|
|
|
|
const API = '/api/awesomelist';
|
|
const root = document.getElementById('awesomelistRoot');
|
|
if (!root) return;
|
|
|
|
let state = { view: 'sectors', data: null, searchTimeout: null, activeListSlug: null, activeSubIdx: null };
|
|
|
|
// ─── Utilities ───────────────────────────────────────
|
|
function esc(s) {
|
|
const d = document.createElement('div');
|
|
d.textContent = s || '';
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function md(s) {
|
|
if (!s) return '';
|
|
let h = esc(s);
|
|
h = h.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
|
|
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
|
h = h.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
|
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
return h;
|
|
}
|
|
|
|
function fmt(n) {
|
|
return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
|
}
|
|
|
|
async function api(endpoint) {
|
|
const res = await fetch(API + endpoint);
|
|
if (!res.ok) throw new Error(`API ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
// ─── Render: Sectors (Level 1) ───────────────────────
|
|
function renderSectors(data) {
|
|
state.view = 'sectors';
|
|
state.data = data;
|
|
let html = '';
|
|
|
|
// Stats bar
|
|
html += `<div class="al-stats-bar">
|
|
<div class="al-stat"><span class="al-stat-label">DATABASE</span><span class="al-stat-value green">● ONLINE</span></div>
|
|
<div class="al-stat"><span class="al-stat-label">TOTAL LISTS</span><span class="al-stat-value amber">${fmt(data.total_lists)}</span></div>
|
|
<div class="al-stat"><span class="al-stat-label">TOTAL ENTRIES</span><span class="al-stat-value">${fmt(data.total_entries)}</span></div>
|
|
<div class="al-stat"><span class="al-stat-label">SECTORS</span><span class="al-stat-value">${data.total_sectors}</span></div>
|
|
<div class="al-stat"><span class="al-stat-label">STATUS</span><span class="al-stat-value green">● DECLASSIFIED</span></div>
|
|
</div>`;
|
|
|
|
// Search
|
|
html += `<div class="al-search-container">
|
|
<span class="al-search-icon">⌕</span>
|
|
<input type="text" class="al-search-input" id="alSearch" placeholder="SEARCH ALL LISTS & ENTRIES..." autocomplete="off">
|
|
<span class="al-search-count" id="alSearchCount"></span>
|
|
</div>`;
|
|
html += `<div class="al-search-results" id="alSearchResults"></div>`;
|
|
|
|
// Sector grid
|
|
html += `<div class="al-sector-grid" id="alSectorGrid">`;
|
|
for (const sector of data.sectors) {
|
|
html += `<div class="al-sector-card" data-code="${esc(sector.code)}">
|
|
<div class="al-sector-icon">${sector.icon}</div>
|
|
<div class="al-sector-code">${esc(sector.code)}</div>
|
|
<div class="al-sector-name">${esc(sector.name)}</div>
|
|
<div class="al-sector-meta">
|
|
<span class="amber">${sector.list_count} LISTS</span>
|
|
<span>${fmt(sector.total_entries)} ENTRIES</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
|
|
root.innerHTML = html;
|
|
bindSectorEvents();
|
|
}
|
|
|
|
// ─── Render: Lists in Sector (Level 2) ──────────────
|
|
function renderSector(sector) {
|
|
state.view = 'sector';
|
|
state.activeListSlug = null;
|
|
let html = '';
|
|
|
|
html += `<div class="al-back" id="alBackSectors">◄ BACK TO SECTORS</div>`;
|
|
|
|
html += `<div class="al-detail-header">
|
|
<div class="al-detail-code">${esc(sector.code)}</div>
|
|
<div class="al-detail-title">${sector.icon} ${esc(sector.name)}</div>
|
|
<div class="al-detail-meta">
|
|
<span class="amber">${sector.list_count} LISTS</span>
|
|
<span>${fmt(sector.total_entries)} ENTRIES</span>
|
|
</div>
|
|
</div>`;
|
|
|
|
// Filter search
|
|
html += `<div class="al-search-container">
|
|
<span class="al-search-icon">⌕</span>
|
|
<input type="text" class="al-search-input" id="alSectorFilter" placeholder="FILTER LISTS IN THIS SECTOR..." autocomplete="off">
|
|
</div>`;
|
|
|
|
// List cards
|
|
html += `<div class="al-list-grid" id="alListGrid">`;
|
|
for (const lst of sector.lists) {
|
|
html += `<div class="al-list-card" data-slug="${esc(lst.slug)}">
|
|
<div class="al-list-title">${esc(lst.title)}</div>
|
|
<div class="al-list-desc">${esc(lst.description || '')}</div>
|
|
<div class="al-list-stats">
|
|
<span class="amber">${fmt(lst.entry_count)} entries</span>
|
|
<span>${lst.subcategory_count} sections</span>
|
|
${lst.stars ? `<span>⭐ ${esc(lst.stars)}</span>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
|
|
// Detail panel
|
|
html += `<div id="alListDetail" style="display:none"></div>`;
|
|
|
|
root.innerHTML = html;
|
|
bindListEvents(sector);
|
|
}
|
|
|
|
// ─── Render: Single List Detail (Level 3) ───────────
|
|
function renderListDetail(listData) {
|
|
state.activeSubIdx = null;
|
|
const panel = document.getElementById('alListDetail');
|
|
if (!panel) return;
|
|
|
|
let html = `<div class="al-list-detail">`;
|
|
html += `<div class="al-list-detail-header">${esc(listData.title)}</div>`;
|
|
if (listData.description) {
|
|
html += `<div class="al-list-detail-desc">${md(listData.description)}</div>`;
|
|
}
|
|
if (listData.github_url) {
|
|
html += `<a href="${esc(listData.github_url)}" target="_blank" rel="noopener" class="al-list-detail-github">📂 ${esc(listData.github_url)}</a>`;
|
|
}
|
|
|
|
// Subcategory cards
|
|
if (listData.subcategories && listData.subcategories.length > 0) {
|
|
html += `<div class="al-sub-grid" id="alSubGrid">`;
|
|
for (let i = 0; i < listData.subcategories.length; i++) {
|
|
const sub = listData.subcategories[i];
|
|
if (sub.entries.length === 0) continue;
|
|
html += `<div class="al-sub-card" data-sub-idx="${i}">
|
|
<div class="al-sub-card-name">${esc(sub.name)}</div>
|
|
<div class="al-sub-card-count">${sub.entries.length} items</div>
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
html += `<div id="alEntriesPanel" style="display:none"></div>`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
panel.innerHTML = html;
|
|
panel.style.display = 'block';
|
|
|
|
// Bind sub card clicks
|
|
panel.querySelectorAll('.al-sub-card').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
const idx = parseInt(card.dataset.subIdx);
|
|
if (state.activeSubIdx === idx) {
|
|
state.activeSubIdx = null;
|
|
card.classList.remove('active');
|
|
document.getElementById('alEntriesPanel').style.display = 'none';
|
|
return;
|
|
}
|
|
state.activeSubIdx = idx;
|
|
panel.querySelectorAll('.al-sub-card').forEach(c => c.classList.remove('active'));
|
|
card.classList.add('active');
|
|
renderEntries(listData.subcategories[idx]);
|
|
});
|
|
});
|
|
|
|
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
|
|
// ─── Render: Entries ─────────────────────────────────
|
|
function renderEntries(sub) {
|
|
const panel = document.getElementById('alEntriesPanel');
|
|
if (!panel) return;
|
|
|
|
let html = `<div class="al-entries-panel">`;
|
|
html += `<div class="al-entries-header">${esc(sub.name)} — ${sub.entries.length} ITEMS</div>`;
|
|
|
|
for (const entry of sub.entries) {
|
|
html += `<div class="al-entry">`;
|
|
html += `<span class="al-entry-bullet">▸</span>`;
|
|
html += `<div class="al-entry-content">`;
|
|
|
|
if (entry.url) {
|
|
html += `<div class="al-entry-name"><a href="${esc(entry.url)}" target="_blank" rel="noopener">${esc(entry.name || entry.url)}</a>`;
|
|
} else {
|
|
html += `<div class="al-entry-name">${md(entry.name || '')}`;
|
|
}
|
|
if (entry.stars) {
|
|
html += `<span class="al-entry-stars">⭐ ${esc(entry.stars)}</span>`;
|
|
}
|
|
html += `</div>`;
|
|
|
|
if (entry.description) {
|
|
html += `<div class="al-entry-desc">${md(entry.description)}</div>`;
|
|
}
|
|
|
|
html += `</div></div>`;
|
|
}
|
|
|
|
html += `</div>`;
|
|
panel.innerHTML = html;
|
|
panel.style.display = 'block';
|
|
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
|
|
// ─── Render: Search Results ──────────────────────────
|
|
function renderSearchResults(data) {
|
|
const container = document.getElementById('alSearchResults');
|
|
const countEl = document.getElementById('alSearchCount');
|
|
const grid = document.getElementById('alSectorGrid');
|
|
|
|
if (!data.results || data.results.length === 0) {
|
|
container.innerHTML = data.query ? '<div class="al-empty">NO MATCHING RESULTS FOUND</div>' : '';
|
|
countEl.textContent = data.query ? '0 RESULTS' : '';
|
|
if (grid) grid.style.display = data.query ? 'none' : '';
|
|
return;
|
|
}
|
|
|
|
countEl.textContent = `${data.total} RESULTS`;
|
|
if (grid) grid.style.display = 'none';
|
|
|
|
let html = '';
|
|
for (const r of data.results) {
|
|
html += `<div class="al-search-result">`;
|
|
if (r.type === 'list') {
|
|
html += `<div class="al-result-breadcrumb">${esc(r.sector_code)} // ${esc(r.sector_name)}</div>`;
|
|
html += `<div class="al-result-name">${esc(r.title)}`;
|
|
if (r.stars) html += `<span class="al-result-stars">⭐ ${esc(r.stars)}</span>`;
|
|
html += `</div>`;
|
|
if (r.description) html += `<div class="al-result-desc">${md(r.description)}</div>`;
|
|
html += `<div style="font-family:'JetBrains Mono';font-size:0.6rem;color:#555;margin-top:0.3rem">${fmt(r.entry_count)} entries</div>`;
|
|
} else {
|
|
html += `<div class="al-result-breadcrumb">${esc(r.list_title)} / ${esc(r.subcategory)}</div>`;
|
|
html += `<div class="al-result-name">`;
|
|
if (r.url) {
|
|
html += `<a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(r.name || r.url)}</a>`;
|
|
} else {
|
|
html += md(r.name);
|
|
}
|
|
if (r.stars) html += `<span class="al-result-stars">⭐ ${esc(r.stars)}</span>`;
|
|
html += `</div>`;
|
|
if (r.description) html += `<div class="al-result-desc">${md(r.description)}</div>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// ─── Event Bindings ──────────────────────────────────
|
|
function bindSectorEvents() {
|
|
document.querySelectorAll('.al-sector-card').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
const code = card.dataset.code;
|
|
const sector = state.data.sectors.find(s => s.code === code);
|
|
if (sector) {
|
|
history.pushState({ view: 'sector', code }, '', `?sector=${code}`);
|
|
renderSector(sector);
|
|
}
|
|
});
|
|
});
|
|
|
|
const searchInput = document.getElementById('alSearch');
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', () => {
|
|
clearTimeout(state.searchTimeout);
|
|
const q = searchInput.value.trim();
|
|
if (q.length < 2) {
|
|
document.getElementById('alSearchResults').innerHTML = '';
|
|
document.getElementById('alSearchCount').textContent = '';
|
|
const grid = document.getElementById('alSectorGrid');
|
|
if (grid) grid.style.display = '';
|
|
return;
|
|
}
|
|
state.searchTimeout = setTimeout(async () => {
|
|
try {
|
|
const data = await api(`/search?q=${encodeURIComponent(q)}`);
|
|
renderSearchResults(data);
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}, 300);
|
|
});
|
|
}
|
|
}
|
|
|
|
function bindListEvents(sector) {
|
|
// Back button
|
|
document.getElementById('alBackSectors').addEventListener('click', () => {
|
|
history.pushState({ view: 'sectors' }, '', window.location.pathname);
|
|
renderSectors(state.data);
|
|
});
|
|
|
|
// List card clicks
|
|
document.querySelectorAll('.al-list-card').forEach(card => {
|
|
card.addEventListener('click', async () => {
|
|
const slug = card.dataset.slug;
|
|
if (state.activeListSlug === slug) {
|
|
state.activeListSlug = null;
|
|
card.classList.remove('active');
|
|
document.getElementById('alListDetail').style.display = 'none';
|
|
return;
|
|
}
|
|
state.activeListSlug = slug;
|
|
document.querySelectorAll('.al-list-card').forEach(c => c.classList.remove('active'));
|
|
card.classList.add('active');
|
|
|
|
const panel = document.getElementById('alListDetail');
|
|
panel.innerHTML = '<div class="al-loading">LOADING DOSSIER...</div>';
|
|
panel.style.display = 'block';
|
|
|
|
try {
|
|
const listData = await api(`/list/${slug}`);
|
|
renderListDetail(listData);
|
|
} catch (e) {
|
|
panel.innerHTML = '<div class="al-empty">FAILED TO LOAD DOSSIER</div>';
|
|
}
|
|
});
|
|
});
|
|
|
|
// Filter
|
|
const filterInput = document.getElementById('alSectorFilter');
|
|
if (filterInput) {
|
|
filterInput.addEventListener('input', () => {
|
|
const q = filterInput.value.trim().toLowerCase();
|
|
document.querySelectorAll('.al-list-card').forEach(card => {
|
|
const title = card.querySelector('.al-list-title').textContent.toLowerCase();
|
|
const desc = card.querySelector('.al-list-desc').textContent.toLowerCase();
|
|
card.style.display = (!q || title.includes(q) || desc.includes(q)) ? '' : 'none';
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── History Navigation ──────────────────────────────
|
|
window.addEventListener('popstate', (e) => {
|
|
if (e.state && e.state.view === 'sector' && state.data) {
|
|
const sector = state.data.sectors.find(s => s.code === e.state.code);
|
|
if (sector) { renderSector(sector); return; }
|
|
}
|
|
if (state.data) renderSectors(state.data);
|
|
});
|
|
|
|
// ─── Init ────────────────────────────────────────────
|
|
async function init() {
|
|
root.innerHTML = '<div class="al-loading">ACCESSING PROPAGANDA DATABASE...</div>';
|
|
try {
|
|
const data = await api('');
|
|
state.data = data;
|
|
|
|
// Check URL params
|
|
const params = new URLSearchParams(window.location.search);
|
|
const sectorCode = params.get('sector');
|
|
if (sectorCode) {
|
|
const sector = data.sectors.find(s => s.code.toLowerCase() === sectorCode.toLowerCase());
|
|
if (sector) { renderSector(sector); return; }
|
|
}
|
|
renderSectors(data);
|
|
} catch (e) {
|
|
root.innerHTML = '<div class="al-empty">FAILED TO ACCESS DATABASE — RETRY LATER</div>';
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
init();
|
|
})();
|