jaeswift-website/js/contraband.js

388 lines
16 KiB
JavaScript

/* ═══════════════════════════════════════════════════════
CONTRABAND // Resource Database Controller
═══════════════════════════════════════════════════════ */
(function () {
'use strict';
const API = '/api/contraband';
const root = document.getElementById('contrabandRoot');
if (!root) return;
let state = { view: 'index', categories: [], searchTimeout: null };
// ─── Utilities ───────────────────────────────────────
function esc(s) {
const d = document.createElement('div');
d.textContent = s || '';
return d.innerHTML;
}
// Mini markdown → HTML (escape first, then convert patterns)
function md(s) {
if (!s) return '';
let h = esc(s);
// Links: [text](url)
h = h.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
// Bold: **text**
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
// Italic: *text*
h = h.replace(/\*([^*]+)\*/g, '<em>$1</em>');
// Inline code: `text`
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.categories = data.categories || [];
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">STARRED</span><span class="crt-stat-value amber">${fmt(data.total_starred)}</span></div>
<div class="crt-stat"><span class="crt-stat-label">CATEGORIES</span><span class="crt-stat-value">${data.total_categories}</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>`;
// Category grid
html += `<div class="crt-grid" id="crtGrid">`;
for (const cat of state.categories) {
if (cat.entry_count === 0) continue;
html += `<div class="crt-card" data-slug="${esc(cat.slug)}">
<span class="crt-card-icon">${cat.icon}</span>
<div class="crt-card-code">${esc(cat.code)}</div>
<div class="crt-card-name">${esc(cat.name)}</div>
<div class="crt-card-meta">
<span class="crt-card-count">${fmt(cat.entry_count)} assets</span>
<span class="crt-card-stars">⭐ ${cat.starred_count}</span>
</div>
<div class="crt-card-status">● ACCESSIBLE</div>
</div>`;
}
html += `</div>`;
// End of grid
root.innerHTML = html;
bindIndexEvents();
}
// ─── Render: Category Detail ─────────────────────────
function renderCategory(cat) {
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(cat.code)}</div>
<div class="crt-detail-title">${esc(cat.icon)} ${esc(cat.name)}</div>
<div class="crt-detail-meta">
<span>${fmt(cat.entry_count)} ASSETS</span>
<span>⭐ ${cat.starred_count} STARRED</span>
<span>${cat.subcategory_count} SECTIONS</span>
</div>
</div>`;
// Search within category
html += `<div class="crt-search-container">
<span class="crt-search-icon">⌕</span>
<input type="text" class="crt-search-input" id="crtCatSearch" placeholder="FILTER THIS CATEGORY..." autocomplete="off">
</div>`;
// Filter buttons
html += `<div class="crt-filters">
<button class="crt-filter-btn active" data-filter="all">ALL (${fmt(cat.entry_count)})</button>
<button class="crt-filter-btn" data-filter="starred">⭐ STARRED (${cat.starred_count})</button>
</div>`;
// Subcategory grid (4-col cards)
html += `<div class="crt-sub-grid" id="crtSubGrid">`;
for (let i = 0; i < cat.subcategories.length; i++) {
const sub = cat.subcategories[i];
if (sub.entries.length === 0 && sub.notes.length === 0) continue;
const starCount = sub.entries.filter(e => e.starred).length;
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>
${starCount > 0 ? `<span class="amber">⭐ ${starCount}</span>` : ''}
</div>
</div>`;
}
html += `</div>`;
// Shared detail panel (populated on card click)
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>`;
// End of grid
root.innerHTML = html;
bindDetailEvents(cat);
}
// ─── 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.category_code)} // ${esc(r.category_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;
loadCategory(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(cat) {
// 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 = cat.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' });
});
});
// Filter buttons
document.querySelectorAll('.crt-filter-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.crt-filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const filter = btn.dataset.filter;
document.querySelectorAll('.crt-entry').forEach(entry => {
if (filter === 'starred') {
entry.style.display = entry.classList.contains('starred') ? '' : 'none';
} else {
entry.style.display = '';
}
});
});
});
// Category search (local filter)
const catSearch = document.getElementById('crtCatSearch');
if (catSearch) {
catSearch.addEventListener('input', () => {
const q = catSearch.value.trim().toLowerCase();
document.querySelectorAll('.crt-entry').forEach(entry => {
if (!q) {
entry.style.display = '';
return;
}
const text = entry.textContent.toLowerCase();
entry.style.display = text.includes(q) ? '' : 'none';
});
});
}
}
// ─── Load Functions ──────────────────────────────────
async function loadIndex() {
root.innerHTML = '<div class="crt-loading">ACCESSING CONTRABAND DATABASE...</div>';
try {
const data = await api('');
renderIndex(data);
history.pushState({ view: 'index' }, '', '/depot/contraband');
} catch (e) {
root.innerHTML = `<div class="crt-empty">DATABASE ACCESS DENIED // ${esc(e.message)}</div>`;
}
}
async function loadCategory(slug) {
root.innerHTML = '<div class="crt-loading">DECRYPTING CATEGORY DATA...</div>';
try {
const data = await api(`/${slug}`);
renderCategory(data);
history.pushState({ view: 'detail', slug }, '', `/depot/contraband?cat=${slug}`);
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (e) {
root.innerHTML = `<div class="crt-empty">CATEGORY NOT FOUND // ${esc(e.message)}</div>`;
}
}
// ─── Handle Back/Forward ─────────────────────────────
window.addEventListener('popstate', (e) => {
if (e.state && e.state.view === 'detail' && e.state.slug) {
loadCategory(e.state.slug);
} else {
loadIndex();
}
});
// ─── Init ────────────────────────────────────────────
function init() {
// Check URL for category param
const params = new URLSearchParams(window.location.search);
const cat = params.get('cat');
if (cat) {
loadCategory(cat);
} else {
loadIndex();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();