352 lines
15 KiB
JavaScript
352 lines
15 KiB
JavaScript
/* ═══════════════════════════════════════════════════════
|
|
RECON // Curated Lists Controller (flattened)
|
|
═══════════════════════════════════════════════════════ */
|
|
(function () {
|
|
'use strict';
|
|
|
|
const API = '/api/awesomelist';
|
|
const root = document.getElementById('awesomelistRoot');
|
|
if (!root) return;
|
|
|
|
let state = { view: 'index', sectors: [], searchTimeout: 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, ',');
|
|
}
|
|
|
|
// ─── API Fetch ───────────────────────────────────────
|
|
async function api(endpoint) {
|
|
const res = await fetch(API + endpoint);
|
|
if (!res.ok) throw new Error(`API ${res.status}`);
|
|
return res.json();
|
|
}
|
|
|
|
// ─── Render: Index View ──────────────────────────────
|
|
function renderIndex(data) {
|
|
state.view = 'index';
|
|
state.sectors = data.sectors || [];
|
|
|
|
let html = '';
|
|
|
|
// Stats bar
|
|
html += `<div class="crt-stats-bar">
|
|
<div class="crt-stat"><span class="crt-stat-label">DATABASE</span><span class="crt-stat-value">● ONLINE</span></div>
|
|
<div class="crt-stat"><span class="crt-stat-label">TOTAL ASSETS</span><span class="crt-stat-value">${fmt(data.total_entries)}</span></div>
|
|
<div class="crt-stat"><span class="crt-stat-label">SECTORS</span><span class="crt-stat-value">${data.total_sectors}</span></div>
|
|
<div class="crt-stat"><span class="crt-stat-label">STATUS</span><span class="crt-stat-value">● DECLASSIFIED</span></div>
|
|
</div>`;
|
|
|
|
// Search
|
|
html += `<div class="crt-search-container">
|
|
<span class="crt-search-icon">⌕</span>
|
|
<input type="text" class="crt-search-input" id="crtSearch" placeholder="SEARCH ALL ASSETS..." autocomplete="off">
|
|
<span class="crt-search-count" id="crtSearchCount"></span>
|
|
</div>`;
|
|
|
|
// Search results container
|
|
html += `<div class="crt-search-results" id="crtSearchResults"></div>`;
|
|
|
|
// Sector grid
|
|
html += `<div class="crt-grid" id="crtGrid">`;
|
|
for (const sec of state.sectors) {
|
|
if (sec.entry_count === 0) continue;
|
|
html += `<div class="crt-card" data-slug="${esc(sec.slug)}">
|
|
<span class="crt-card-icon">${sec.icon}</span>
|
|
<div class="crt-card-code">${esc(sec.code)}</div>
|
|
<div class="crt-card-name">${esc(sec.name)}</div>
|
|
<div class="crt-card-meta">
|
|
<span class="crt-card-count">${fmt(sec.entry_count)} assets</span>
|
|
<span class="crt-card-stars">${sec.subcategory_count} sections</span>
|
|
</div>
|
|
<div class="crt-card-status">● ACCESSIBLE</div>
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
|
|
root.innerHTML = html;
|
|
bindIndexEvents();
|
|
}
|
|
|
|
// ─── Render: Sector Detail ───────────────────────────
|
|
function renderSector(sec) {
|
|
state.view = 'detail';
|
|
let html = '';
|
|
|
|
// Back button
|
|
html += `<div class="crt-back" id="crtBack">◄ BACK TO INDEX</div>`;
|
|
|
|
// Detail header
|
|
html += `<div class="crt-detail-header">
|
|
<div class="crt-detail-code">${esc(sec.code)}</div>
|
|
<div class="crt-detail-title">${esc(sec.icon)} ${esc(sec.name)}</div>
|
|
<div class="crt-detail-meta">
|
|
<span>${fmt(sec.total_entries)} ASSETS</span>
|
|
<span>${sec.subcategory_count} SECTIONS</span>
|
|
</div>
|
|
</div>`;
|
|
|
|
// Search within sector
|
|
html += `<div class="crt-search-container">
|
|
<span class="crt-search-icon">⌕</span>
|
|
<input type="text" class="crt-search-input" id="crtCatSearch" placeholder="FILTER THIS SECTOR..." autocomplete="off">
|
|
</div>`;
|
|
|
|
// Subcategory grid (3-col cards)
|
|
html += `<div class="crt-sub-grid" id="crtSubGrid">`;
|
|
for (let i = 0; i < sec.subcategories.length; i++) {
|
|
const sub = sec.subcategories[i];
|
|
if (sub.entries.length === 0) continue;
|
|
html += `<div class="crt-sub-card" data-sub-idx="${i}">
|
|
<div class="crt-sub-card-name">${esc(sub.name)}</div>
|
|
<div class="crt-sub-card-stats">
|
|
<span>${sub.entries.length} items</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
html += `</div>`;
|
|
|
|
// Shared detail panel
|
|
html += `<div class="crt-sub-detail" id="crtSubDetail" style="display:none">
|
|
<div class="crt-sub-detail-header" id="crtSubDetailHeader"></div>
|
|
<div class="crt-sub-detail-notes" id="crtSubDetailNotes"></div>
|
|
<div class="crt-sub-detail-entries" id="crtSubDetailEntries"></div>
|
|
</div>`;
|
|
|
|
root.innerHTML = html;
|
|
bindDetailEvents(sec);
|
|
}
|
|
|
|
// ─── Render: Single Entry ────────────────────────────
|
|
function renderEntry(entry) {
|
|
const starClass = entry.starred ? ' starred' : '';
|
|
const starIcon = entry.starred ? '⭐' : '·';
|
|
let html = `<div class="crt-entry${starClass}">`;
|
|
html += `<span class="crt-entry-star">${starIcon}</span>`;
|
|
html += `<div class="crt-entry-content">`;
|
|
|
|
if (entry.url) {
|
|
html += `<div class="crt-entry-name"><a href="${esc(entry.url)}" target="_blank" rel="noopener">${esc(entry.name || entry.url)}</a></div>`;
|
|
} else if (entry.name) {
|
|
html += `<div class="crt-entry-name">${md(entry.name)}</div>`;
|
|
}
|
|
|
|
if (entry.description) {
|
|
html += `<div class="crt-entry-desc">${md(entry.description)}</div>`;
|
|
}
|
|
|
|
if (entry.extra_links && entry.extra_links.length > 0) {
|
|
html += `<div class="crt-entry-extra">`;
|
|
for (const link of entry.extra_links) {
|
|
html += `<a href="${esc(link.url)}" target="_blank" rel="noopener">${esc(link.name)}</a>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
|
|
html += `</div></div>`;
|
|
return html;
|
|
}
|
|
|
|
// ─── Render: Search Results ──────────────────────────
|
|
function renderSearchResults(data) {
|
|
const container = document.getElementById('crtSearchResults');
|
|
const countEl = document.getElementById('crtSearchCount');
|
|
const grid = document.getElementById('crtGrid');
|
|
|
|
if (!data.results || data.results.length === 0) {
|
|
container.innerHTML = data.query ? '<div class="crt-empty">NO MATCHING ASSETS FOUND</div>' : '';
|
|
countEl.textContent = data.query ? '0 RESULTS' : '';
|
|
if (grid) grid.style.display = data.query ? 'none' : '';
|
|
return;
|
|
}
|
|
|
|
countEl.textContent = `${data.results.length} RESULTS`;
|
|
if (grid) grid.style.display = 'none';
|
|
|
|
let html = '';
|
|
for (const r of data.results) {
|
|
const starIcon = r.starred ? '⭐ ' : '';
|
|
html += `<div class="crt-search-result">`;
|
|
html += `<div class="crt-result-breadcrumb">${esc(r.sector_code)} // ${esc(r.sector_name)} / ${esc(r.subcategory)}</div>`;
|
|
html += `<div class="crt-result-name">${starIcon}`;
|
|
if (r.url) {
|
|
html += `<a href="${esc(r.url)}" target="_blank" rel="noopener">${esc(r.name || r.url)}</a>`;
|
|
} else {
|
|
html += md(r.name);
|
|
}
|
|
html += `</div>`;
|
|
if (r.description) {
|
|
html += `<div class="crt-result-desc">${md(r.description)}</div>`;
|
|
}
|
|
html += `</div>`;
|
|
}
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// ─── Event Bindings: Index ───────────────────────────
|
|
function bindIndexEvents() {
|
|
// Card clicks
|
|
document.querySelectorAll('.crt-card').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
const slug = card.dataset.slug;
|
|
loadSector(slug);
|
|
});
|
|
});
|
|
|
|
// Global search
|
|
const searchInput = document.getElementById('crtSearch');
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', () => {
|
|
clearTimeout(state.searchTimeout);
|
|
const q = searchInput.value.trim();
|
|
if (q.length < 2) {
|
|
renderSearchResults({ query: '', results: [] });
|
|
return;
|
|
}
|
|
state.searchTimeout = setTimeout(async () => {
|
|
try {
|
|
const data = await api(`/search?q=${encodeURIComponent(q)}&limit=100`);
|
|
renderSearchResults(data);
|
|
} catch (e) {
|
|
console.warn('Search failed:', e);
|
|
}
|
|
}, 300);
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Event Bindings: Detail ──────────────────────────
|
|
function bindDetailEvents(sec) {
|
|
// Back button
|
|
document.getElementById('crtBack').addEventListener('click', () => {
|
|
loadIndex();
|
|
});
|
|
|
|
// Subcategory card clicks
|
|
document.querySelectorAll('.crt-sub-card').forEach(card => {
|
|
card.addEventListener('click', () => {
|
|
const idx = parseInt(card.dataset.subIdx);
|
|
const sub = sec.subcategories[idx];
|
|
const panel = document.getElementById('crtSubDetail');
|
|
const headerEl = document.getElementById('crtSubDetailHeader');
|
|
const notesEl = document.getElementById('crtSubDetailNotes');
|
|
const entriesEl = document.getElementById('crtSubDetailEntries');
|
|
|
|
// If clicking the already active card, collapse
|
|
const wasActive = card.classList.contains('active');
|
|
document.querySelectorAll('.crt-sub-card').forEach(c => c.classList.remove('active'));
|
|
|
|
if (wasActive) {
|
|
panel.style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
card.classList.add('active');
|
|
|
|
// Populate header
|
|
headerEl.innerHTML = `<span class="crt-sub-toggle">▾</span> <span class="crt-sub-detail-name">${esc(sub.name)}</span> <span class="crt-sub-count">${sub.entries.length} items</span>`;
|
|
|
|
// Populate notes
|
|
if (sub.notes && sub.notes.length > 0) {
|
|
let nh = '';
|
|
for (const note of sub.notes) nh += `⚠ ${esc(note)}<br>`;
|
|
notesEl.innerHTML = nh;
|
|
notesEl.style.display = '';
|
|
} else {
|
|
notesEl.innerHTML = '';
|
|
notesEl.style.display = 'none';
|
|
}
|
|
|
|
// Populate entries
|
|
let eh = '';
|
|
for (const entry of sub.entries) eh += renderEntry(entry);
|
|
entriesEl.innerHTML = eh;
|
|
|
|
panel.style.display = '';
|
|
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
});
|
|
});
|
|
|
|
// Category search (local filter on subcategory cards)
|
|
const catSearch = document.getElementById('crtCatSearch');
|
|
if (catSearch) {
|
|
catSearch.addEventListener('input', () => {
|
|
const q = catSearch.value.trim().toLowerCase();
|
|
document.querySelectorAll('.crt-sub-card').forEach(card => {
|
|
if (!q) {
|
|
card.style.display = '';
|
|
return;
|
|
}
|
|
const text = card.textContent.toLowerCase();
|
|
card.style.display = text.includes(q) ? '' : 'none';
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// ─── Load Functions ──────────────────────────────────
|
|
async function loadIndex() {
|
|
root.innerHTML = '<div class="crt-loading">ACCESSING RECON DATABASE...</div>';
|
|
try {
|
|
const data = await api('');
|
|
renderIndex(data);
|
|
history.pushState({ view: 'index' }, '', '/recon/awesomelist');
|
|
} catch (e) {
|
|
root.innerHTML = `<div class="crt-empty">DATABASE ACCESS DENIED // ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
async function loadSector(slug) {
|
|
root.innerHTML = '<div class="crt-loading">DECRYPTING SECTOR DATA...</div>';
|
|
try {
|
|
const data = await api(`/${slug}`);
|
|
renderSector(data);
|
|
history.pushState({ view: 'detail', slug }, '', `/recon/awesomelist?sector=${slug}`);
|
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
|
} catch (e) {
|
|
root.innerHTML = `<div class="crt-empty">SECTOR NOT FOUND // ${esc(e.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
// ─── Handle Back/Forward ─────────────────────────────
|
|
window.addEventListener('popstate', (e) => {
|
|
if (e.state && e.state.view === 'detail' && e.state.slug) {
|
|
loadSector(e.state.slug);
|
|
} else {
|
|
loadIndex();
|
|
}
|
|
});
|
|
|
|
// ─── Init ────────────────────────────────────────────
|
|
function init() {
|
|
const params = new URLSearchParams(window.location.search);
|
|
const sector = params.get('sector');
|
|
if (sector) {
|
|
loadSector(sector);
|
|
} else {
|
|
loadIndex();
|
|
}
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|