jaeswift-website/js/awesomelist.js

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();
})();