tractatus/public/js/docs-app.js
TheFlow e0297bb7c4 fix: Remove broken PDF download fallback, use explicit download_formats
The sidebar download button was assuming every document had a PDF at
/downloads/{slug}.pdf, causing 404s for 70 documents where the slug
didn't match a file (or no PDF existed). Now only shows download
buttons when download_formats.pdf is explicitly set in MongoDB.

Corresponding migration populated download_formats.pdf for 72 documents
that have actual PDF files on disk.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 17:25:43 +13:00

1175 lines
40 KiB
JavaScript

let documents = [];
let currentDocument = null;
let documentCards = null;
let currentLanguage = 'en'; // Default language
// Active sidebar filters
const activeFilters = {
document_type: '',
audience: ''
};
// UI translations for full page i18n
const UI_TRANSLATIONS = {
en: {
pageTitle: 'Framework Documentation',
pageSubtitle: 'Technical specifications, guides, and reference materials',
documentsHeading: 'Documents',
searchButton: 'Search',
backToDocuments: 'Back to Documents',
selectDocument: 'Select a Document',
selectDocumentDesc: 'Choose a document from the sidebar to begin reading',
loadingDocument: 'Loading document...',
errorLoadingDoc: 'Error loading document',
tableOfContents: 'Table of Contents',
downloadPdf: 'Download PDF',
github: 'GitHub',
publicRepository: 'Public Repository',
publicRepositoryDesc: 'Source code, examples & contributions',
readmeQuickStart: 'README & Quick Start',
readmeQuickStartDesc: 'Installation and getting started guide',
categories: {
'getting-started': 'Getting Started',
'resources': 'Resources',
'research-theory': 'Research & Theory',
'technical-reference': 'Technical Reference',
'advanced-topics': 'Advanced Topics',
'business-leadership': 'Business & Leadership'
},
filters: {
allTypes: 'All types',
allAudiences: 'All audiences',
clear: 'Clear',
showingDocs: '{count} documents',
documentTypes: {
'working-paper': 'Working paper',
'case-study': 'Case study',
'technical-report': 'Technical report',
'guide': 'Guide',
'reference': 'Reference',
'brief': 'Brief'
},
audiences: {
'researcher': 'Researcher',
'implementer': 'Implementer',
'leader': 'Leader',
'general': 'General'
}
}
},
de: {
pageTitle: 'Framework-Dokumentation',
pageSubtitle: 'Technische Spezifikationen, Leitfäden und Referenzmaterialien',
documentsHeading: 'Dokumente',
searchButton: 'Suchen',
backToDocuments: 'Zurück zu Dokumenten',
selectDocument: 'Dokument auswählen',
selectDocumentDesc: 'Wählen Sie ein Dokument aus der Seitenleiste, um mit dem Lesen zu beginnen',
loadingDocument: 'Dokument wird geladen...',
errorLoadingDoc: 'Fehler beim Laden des Dokuments',
tableOfContents: 'Inhaltsverzeichnis',
downloadPdf: 'PDF herunterladen',
github: 'GitHub',
publicRepository: 'Öffentliches Repository',
publicRepositoryDesc: 'Quellcode, Beispiele und Beiträge',
readmeQuickStart: 'README & Schnellstart',
readmeQuickStartDesc: 'Installation und Einstiegsanleitung',
categories: {
'getting-started': 'Erste Schritte',
'resources': 'Ressourcen',
'research-theory': 'Forschung & Theorie',
'technical-reference': 'Technische Referenz',
'advanced-topics': 'Fortgeschrittene Themen',
'business-leadership': 'Business & Führung'
},
filters: {
allTypes: 'Alle Typen',
allAudiences: 'Alle Zielgruppen',
clear: 'Zurücksetzen',
showingDocs: '{count} Dokumente',
documentTypes: {
'working-paper': 'Arbeitspapier',
'case-study': 'Fallstudie',
'technical-report': 'Technischer Bericht',
'guide': 'Leitfaden',
'reference': 'Referenz',
'brief': 'Kurzfassung'
},
audiences: {
'researcher': 'Forscher',
'implementer': 'Umsetzer',
'leader': 'Führungskraft',
'general': 'Allgemein'
}
}
},
fr: {
pageTitle: 'Documentation du Framework',
pageSubtitle: 'Spécifications techniques, guides et matériels de référence',
documentsHeading: 'Documents',
searchButton: 'Rechercher',
backToDocuments: 'Retour aux documents',
selectDocument: 'Sélectionner un document',
selectDocumentDesc: 'Choisissez un document dans la barre latérale pour commencer la lecture',
loadingDocument: 'Chargement du document...',
errorLoadingDoc: 'Erreur lors du chargement du document',
tableOfContents: 'Table des matières',
downloadPdf: 'Télécharger PDF',
github: 'GitHub',
publicRepository: 'Dépôt public',
publicRepositoryDesc: 'Code source, exemples et contributions',
readmeQuickStart: 'README & Démarrage rapide',
readmeQuickStartDesc: 'Guide d\'installation et de démarrage',
categories: {
'getting-started': 'Premiers pas',
'resources': 'Ressources',
'research-theory': 'Recherche & Théorie',
'technical-reference': 'Référence technique',
'advanced-topics': 'Sujets avancés',
'business-leadership': 'Business & Leadership'
},
filters: {
allTypes: 'Tous les types',
allAudiences: 'Tous les publics',
clear: 'Effacer',
showingDocs: '{count} documents',
documentTypes: {
'working-paper': 'Document de travail',
'case-study': 'Étude de cas',
'technical-report': 'Rapport technique',
'guide': 'Guide',
'reference': 'Référence',
'brief': 'Résumé'
},
audiences: {
'researcher': 'Chercheur',
'implementer': 'Implémenteur',
'leader': 'Dirigeant',
'general': 'Général'
}
}
},
mi: {
pageTitle: 'Ngā Tuhinga Anga',
pageSubtitle: 'Ngā tautukunga hangarau, ngā aratohu, me ngā rauemi tohutoro',
documentsHeading: 'Ngā Tuhinga',
searchButton: 'Rapu',
backToDocuments: 'Hoki ki ngā Tuhinga',
selectDocument: 'Kōwhiria he Tuhinga',
selectDocumentDesc: 'Kōwhiria he tuhinga mai i te taha ki te tīmata pānui',
loadingDocument: 'Kei te uta te tuhinga...',
errorLoadingDoc: 'He hapa i te utanga o te tuhinga',
tableOfContents: 'Rārangi Kaupapa',
downloadPdf: 'Tikiake PDF',
github: 'GitHub',
publicRepository: 'Pūtahi Tūmatanui',
publicRepositoryDesc: 'Waehere pūtake, tauira me ngā koha',
readmeQuickStart: 'README & Tīmata Tere',
readmeQuickStartDesc: 'Aratohu whakauru me te tīmata',
categories: {
'getting-started': 'Tīmata',
'resources': 'Ngā Rauemi',
'research-theory': 'Rangahau & Ariā',
'technical-reference': 'Tohutoro Hangarau',
'advanced-topics': 'Kaupapa Matatau',
'business-leadership': 'Pakihi & Hautūtanga'
},
filters: {
allTypes: 'Ngā momo katoa',
allAudiences: 'Ngā hunga katoa',
clear: 'Ūkui',
showingDocs: '{count} tuhinga',
documentTypes: {
'working-paper': 'Pepa mahi',
'case-study': 'Rangahau āhua',
'technical-report': 'Pūrongo hangarau',
'guide': 'Aratohu',
'reference': 'Tohutoro',
'brief': 'Whakarāpopototanga'
},
audiences: {
'researcher': 'Kairangahau',
'implementer': 'Kaiwhakatinana',
'leader': 'Kaihautū',
'general': 'Whānui'
}
}
}
};
// Get current UI translations
function getUITranslations(lang = currentLanguage) {
return UI_TRANSLATIONS[lang] || UI_TRANSLATIONS.en;
}
// Update all page UI elements with current language translations
function updatePageUI(lang = currentLanguage) {
const t = getUITranslations(lang);
// Update page header
const allPageTitles = Object.values(UI_TRANSLATIONS).map(t => t.pageTitle);
const pageTitle = document.querySelector('h1');
if (pageTitle && allPageTitles.includes(pageTitle.textContent)) {
pageTitle.textContent = t.pageTitle;
}
const allPageSubtitles = Object.values(UI_TRANSLATIONS).map(t => t.pageSubtitle);
const pageSubtitle = document.querySelector('.text-gray-600.mt-2');
if (pageSubtitle && allPageSubtitles.includes(pageSubtitle.textContent)) {
pageSubtitle.textContent = t.pageSubtitle;
}
// Update search button
const searchBtn = document.querySelector('#open-search-modal-btn span');
if (searchBtn) {
searchBtn.textContent = t.searchButton;
}
// Update sidebar Documents heading
const allDocsHeadings = Object.values(UI_TRANSLATIONS).map(t => t.documentsHeading);
const docsHeading = document.querySelector('aside h3');
if (docsHeading && allDocsHeadings.includes(docsHeading.textContent)) {
docsHeading.textContent = t.documentsHeading;
}
// Update GitHub section heading
const allGithubLabels = Object.values(UI_TRANSLATIONS).map(t => t.github);
const githubHeadings = document.querySelectorAll('aside h3');
githubHeadings.forEach(heading => {
if (allGithubLabels.includes(heading.textContent.trim())) {
const textNode = Array.from(heading.childNodes).find(node => node.nodeType === Node.TEXT_NODE);
if (textNode) {
textNode.textContent = t.github;
}
}
});
// Update GitHub links
const allRepoTitles = Object.values(UI_TRANSLATIONS).map(t => t.publicRepository);
const allReadmeTitles = Object.values(UI_TRANSLATIONS).map(t => t.readmeQuickStart);
const allRepoDescs = Object.values(UI_TRANSLATIONS).map(t => t.publicRepositoryDesc);
const allReadmeDescs = Object.values(UI_TRANSLATIONS).map(t => t.readmeQuickStartDesc);
const githubLinks = document.querySelectorAll('aside a[href*="github.com"]');
githubLinks.forEach(link => {
const titleDiv = link.querySelector('.text-sm.font-medium');
const descDiv = link.querySelector('.text-xs.text-gray-500');
if (titleDiv) {
if (allRepoTitles.includes(titleDiv.textContent)) {
titleDiv.textContent = t.publicRepository;
} else if (allReadmeTitles.includes(titleDiv.textContent)) {
titleDiv.textContent = t.readmeQuickStart;
}
}
if (descDiv) {
if (allRepoDescs.includes(descDiv.textContent)) {
descDiv.textContent = t.publicRepositoryDesc;
} else if (allReadmeDescs.includes(descDiv.textContent)) {
descDiv.textContent = t.readmeQuickStartDesc;
}
}
});
// Update Back to Documents button
const backBtn = document.querySelector('#back-to-docs-btn span');
if (backBtn) {
backBtn.textContent = t.backToDocuments;
}
// Update Select Document placeholder (if visible)
const selectDocHeading = document.getElementById('select-document-heading');
if (selectDocHeading) {
selectDocHeading.textContent = t.selectDocument;
}
const selectDocDesc = document.getElementById('select-document-desc');
if (selectDocDesc) {
selectDocDesc.textContent = t.selectDocumentDesc;
}
// Update page title tag
document.title = `${t.pageTitle} | Tractatus AI Safety`;
// Update filter dropdown labels
populateFilters();
updateFilterBar();
}
// Initialize card-based viewer
if (typeof DocumentCards !== 'undefined') {
documentCards = new DocumentCards('document-content');
}
// Detect language from URL, localStorage, or i18n system
function detectLanguage() {
// Priority 1: URL parameter
const urlParams = new URLSearchParams(window.location.search);
const urlLang = urlParams.get('lang');
if (urlLang) {
return urlLang;
}
// Priority 2: localStorage
const storedLang = localStorage.getItem('tractatus-lang');
if (storedLang) {
return storedLang;
}
// Priority 3: i18n system
if (window.I18n && window.I18n.currentLang) {
return window.I18n.currentLang;
}
// Default: English
return 'en';
}
// Update URL with language parameter
function updateURL(slug, lang) {
const url = new URL(window.location);
url.searchParams.set('doc', slug);
if (lang && lang !== 'en') {
url.searchParams.set('lang', lang);
} else {
url.searchParams.delete('lang');
}
window.history.pushState({}, '', url);
}
// Listen for language changes from i18n system
if (typeof window !== 'undefined') {
window.addEventListener('languageChanged', async (e) => {
const newLang = e.detail.language;
currentLanguage = newLang;
// Update page UI (hero section, sidebar headings, etc.)
updatePageUI(newLang);
// Remember current document slug before reloading list
const currentSlug = currentDocument ? currentDocument.slug : null;
// Update URL lang parameter BEFORE reloading documents
// This ensures detectLanguage() reads the correct language from URL
if (currentSlug) {
updateURL(currentSlug, newLang);
}
// Reload document list to show translated category labels and document titles
await loadDocuments();
// Explicitly reload current document to ensure it updates
// (loadDocuments auto-loads from URL, but explicit call ensures it happens)
if (currentSlug) {
await loadDocument(currentSlug, newLang);
}
});
// Initialize language on i18n ready
window.addEventListener('i18nInitialized', (e) => {
currentLanguage = e.detail.language;
});
}
// Document categorization - Final 5 categories (curated for public docs)
const CATEGORIES = {
'getting-started': {
icon: '📚',
description: 'Introduction, core concepts, and glossary',
order: 1,
color: 'blue',
bgColor: 'bg-blue-50',
borderColor: 'border-l-4 border-blue-500',
textColor: 'text-blue-700',
collapsed: false
},
'resources': {
icon: '📖',
description: 'Implementation guides and reference materials',
order: 2,
color: 'amber',
bgColor: 'bg-amber-50',
borderColor: 'border-l-4 border-amber-500',
textColor: 'text-amber-700',
collapsed: false
},
'research-theory': {
icon: '🔬',
description: 'Research papers, case studies, theoretical foundations',
order: 3,
color: 'purple',
bgColor: 'bg-purple-50',
borderColor: 'border-l-4 border-purple-500',
textColor: 'text-purple-700',
collapsed: true
},
'technical-reference': {
icon: '🔌',
description: 'API documentation, code examples, architecture',
order: 4,
color: 'green',
bgColor: 'bg-green-50',
borderColor: 'border-l-4 border-green-500',
textColor: 'text-green-700',
collapsed: true
},
'advanced-topics': {
icon: '🎓',
description: 'Value pluralism, organizational theory, advanced concepts',
order: 5,
color: 'teal',
bgColor: 'bg-teal-50',
borderColor: 'border-l-4 border-teal-500',
textColor: 'text-teal-700',
collapsed: true
},
'business-leadership': {
icon: '💼',
description: 'Business cases, ROI analysis, executive briefs',
order: 6,
color: 'pink',
bgColor: 'bg-pink-50',
borderColor: 'border-l-4 border-pink-500',
textColor: 'text-pink-700',
collapsed: true
}
};
// Documents to hide (internal/confidential)
const HIDDEN_DOCS = [
'security-audit-report',
'koha-production-deployment',
'koha-stripe-payment',
'appendix-e-contact',
'cover-letter'
];
// Categorize a document using database category field
// New granular category system for better document organization
function categorizeDocument(doc) {
const slug = doc.slug.toLowerCase();
// Skip hidden documents
if (HIDDEN_DOCS.some(hidden => slug.includes(hidden))) {
return null;
}
// Use category from database
const category = doc.category || 'resources';
// Validate category exists in CATEGORIES constant
if (CATEGORIES[category]) {
return category;
}
// Fallback to resources for uncategorized
console.warn(`Document "${doc.title}" has invalid category "${category}", using fallback to resources`);
return 'resources';
}
// Group documents by category
function groupDocuments(docs) {
const grouped = {};
// Initialize all categories
Object.keys(CATEGORIES).forEach(key => {
grouped[key] = [];
});
// Categorize each document (already sorted by order from API)
docs.forEach(doc => {
const category = categorizeDocument(doc);
if (category && grouped[category]) {
grouped[category].push(doc);
}
});
return grouped;
}
// Render document link with download button
function renderDocLink(doc, isHighlighted = false) {
const highlightClass = isHighlighted ? 'text-blue-700 bg-blue-50 border border-blue-200' : '';
// Get translated title if available and language is not English
let displayTitle = doc.title;
if (currentLanguage !== 'en' && doc.translations && doc.translations[currentLanguage]) {
displayTitle = doc.translations[currentLanguage].title || doc.title;
}
// Determine if PDF download is available and get PDF path
// First check if document has explicit download_formats.pdf
let pdfPath = null;
let hasPDF = false;
if (doc.download_formats && doc.download_formats.pdf) {
pdfPath = doc.download_formats.pdf;
hasPDF = true;
}
// Add download button styling
const paddingClass = hasPDF ? 'pr-10' : 'pr-3';
return `
<div class="relative mb-1">
<button class="doc-link w-full text-left px-3 py-2 ${paddingClass} rounded text-sm hover:bg-blue-50 transition ${highlightClass}"
data-slug="${doc.slug}">
<div class="font-medium text-gray-900">${displayTitle}</div>
</button>
${hasPDF ? `
<a href="${pdfPath}"
target="_blank"
rel="noopener noreferrer"
class="doc-download-link"
title="Download PDF (opens in new tab)">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</a>
` : ''}
</div>
`;
}
// Populate filter dropdowns with translated labels from loaded documents
function populateFilters() {
const t = getUITranslations(currentLanguage);
const f = t.filters || {};
// Document type dropdown
const typeSelect = document.getElementById('filter-doc-type');
if (typeSelect) {
const types = [...new Set(documents.map(d => d.document_type).filter(Boolean))].sort();
const currentVal = typeSelect.value;
typeSelect.innerHTML = `<option value="">${f.allTypes || 'All types'}</option>`;
types.forEach(type => {
const label = (f.documentTypes && f.documentTypes[type]) || type;
typeSelect.innerHTML += `<option value="${type}">${label}</option>`;
});
typeSelect.value = currentVal || activeFilters.document_type;
}
// Audience dropdown
const audSelect = document.getElementById('filter-doc-audience');
if (audSelect) {
const audiences = [...new Set(documents.map(d => d.audience).filter(Boolean))].sort();
const currentVal = audSelect.value;
audSelect.innerHTML = `<option value="">${f.allAudiences || 'All audiences'}</option>`;
audiences.forEach(aud => {
const label = (f.audiences && f.audiences[aud]) || aud;
audSelect.innerHTML += `<option value="${aud}">${label}</option>`;
});
audSelect.value = currentVal || activeFilters.audience;
}
// Clear button label
const clearBtn = document.getElementById('clear-sidebar-filters');
if (clearBtn) {
clearBtn.textContent = f.clear || 'Clear';
}
}
// Apply active filters to documents array
function getFilteredDocuments() {
return documents.filter(doc => {
if (activeFilters.document_type && doc.document_type !== activeFilters.document_type) return false;
if (activeFilters.audience && doc.audience !== activeFilters.audience) return false;
return true;
});
}
// Update the active filters bar (count + clear button visibility)
function updateFilterBar() {
const bar = document.getElementById('active-filters-bar');
const countEl = document.getElementById('filter-result-count');
if (!bar || !countEl) return;
const hasFilters = activeFilters.document_type || activeFilters.audience;
if (hasFilters) {
const filtered = getFilteredDocuments();
const t = getUITranslations(currentLanguage);
const f = t.filters || {};
const template = f.showingDocs || '{count} documents';
countEl.textContent = template.replace('{count}', filtered.length);
bar.classList.remove('hidden');
bar.classList.add('flex');
} else {
bar.classList.add('hidden');
bar.classList.remove('flex');
}
}
// Re-render the document list with current filters applied
function renderFilteredDocuments() {
const filtered = getFilteredDocuments();
const grouped = groupDocuments(filtered);
const listEl = document.getElementById('document-list');
if (!listEl) return;
let html = '';
const sortedCategories = Object.entries(CATEGORIES).sort((a, b) => a[1].order - b[1].order);
const t = getUITranslations(currentLanguage);
sortedCategories.forEach(([categoryId, category]) => {
const docs = grouped[categoryId] || [];
if (docs.length === 0) return;
const categoryLabel = t.categories[categoryId] || categoryId;
html += `
<div class="category-section mb-4" data-category="${categoryId}">
<button class="category-toggle w-full flex items-center justify-between px-3 py-3 text-sm font-bold ${category.textColor} ${category.bgColor} ${category.borderColor} rounded-r hover:shadow-md transition-all"
data-category="${categoryId}"
data-collapsed="false">
<span class="flex items-center gap-2">
<span class="category-icon">${category.icon}</span>
<span>${categoryLabel}</span>
<span class="text-xs font-normal opacity-70">(${docs.length})</span>
</span>
<svg class="category-arrow w-5 h-5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div class="category-docs mt-2 pl-2" data-category="${categoryId}" data-collapsed="false">
`;
docs.forEach(doc => {
const isHighlighted = categoryId === 'getting-started' && doc.order === 1;
html += renderDocLink(doc, isHighlighted);
});
html += `
</div>
</div>
`;
});
if (html === '') {
html = '<div class="text-sm text-gray-500 py-2 px-3">No documents match the selected filters</div>';
}
listEl.innerHTML = html;
updateFilterBar();
// Re-highlight current document if loaded
if (currentDocument) {
document.querySelectorAll('.doc-link').forEach(el => {
if (el.dataset.slug === currentDocument.slug) {
el.classList.add('bg-blue-100', 'text-blue-900');
}
});
}
}
// Load document list
async function loadDocuments() {
try {
// Fetch public documents
const response = await fetch('/api/documents');
const data = await response.json();
documents = data.documents || [];
// Populate filter dropdowns with values from loaded documents
populateFilters();
// Read filters from URL on initial load
const urlFilters = new URLSearchParams(window.location.search);
if (urlFilters.get('type') && !activeFilters.document_type) {
activeFilters.document_type = urlFilters.get('type');
const typeSelect = document.getElementById('filter-doc-type');
if (typeSelect) typeSelect.value = activeFilters.document_type;
}
if (urlFilters.get('audience') && !activeFilters.audience) {
activeFilters.audience = urlFilters.get('audience');
const audSelect = document.getElementById('filter-doc-audience');
if (audSelect) audSelect.value = activeFilters.audience;
}
// Apply filters
const filteredDocs = getFilteredDocuments();
const listEl = document.getElementById('document-list');
if (filteredDocs.length === 0 && documents.length === 0) {
listEl.innerHTML = '<div class="text-sm text-gray-500">No documents available</div>';
return;
}
// Group documents by category (filtered)
const grouped = groupDocuments(filteredDocs);
let html = '';
// Render categories in order
const sortedCategories = Object.entries(CATEGORIES)
.sort((a, b) => a[1].order - b[1].order);
const t = getUITranslations(currentLanguage);
sortedCategories.forEach(([categoryId, category]) => {
const docs = grouped[categoryId] || [];
if (docs.length === 0) return;
const isCollapsed = category.collapsed || false;
const categoryLabel = t.categories[categoryId] || categoryId;
// Category header
html += `
<div class="category-section mb-4" data-category="${categoryId}">
<button class="category-toggle w-full flex items-center justify-between px-3 py-3 text-sm font-bold ${category.textColor} ${category.bgColor} ${category.borderColor} rounded-r hover:shadow-md transition-all"
data-category="${categoryId}"
data-collapsed="${isCollapsed}">
<span class="flex items-center gap-2">
<span class="category-icon">${category.icon}</span>
<span>${categoryLabel}</span>
<span class="text-xs font-normal opacity-70">(${docs.length})</span>
</span>
<svg class="category-arrow w-5 h-5 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div class="category-docs mt-2 pl-2" data-category="${categoryId}" data-collapsed="${isCollapsed}">
`;
// Render documents in category
docs.forEach(doc => {
// Highlight the first document in Getting Started category
const isHighlighted = categoryId === 'getting-started' && doc.order === 1;
html += renderDocLink(doc, isHighlighted);
});
html += `
</div>
</div>
`;
});
listEl.innerHTML = html;
updateFilterBar();
// Apply collapsed state to categories (CSP-compliant - no inline styles)
listEl.querySelectorAll('.category-docs[data-collapsed="true"]').forEach(docsEl => {
docsEl.style.display = 'none';
});
listEl.querySelectorAll('.category-toggle[data-collapsed="true"] .category-arrow').forEach(arrowEl => {
arrowEl.style.transform = 'rotate(-90deg)';
});
// Add event delegation for document links
listEl.addEventListener('click', function(e) {
// Check for download link first (prevent document load when clicking download)
const downloadLink = e.target.closest('.doc-download-link');
if (downloadLink) {
e.stopPropagation();
return;
}
const button = e.target.closest('.doc-link');
if (button && button.dataset.slug) {
e.preventDefault();
loadDocument(button.dataset.slug);
return;
}
// Category toggle
const toggle = e.target.closest('.category-toggle');
if (toggle) {
const categoryId = toggle.dataset.category;
const docsEl = listEl.querySelector(`.category-docs[data-category="${categoryId}"]`);
const arrowEl = toggle.querySelector('.category-arrow');
if (docsEl.style.display === 'none') {
docsEl.style.display = 'block';
arrowEl.style.transform = 'rotate(0deg)';
} else {
docsEl.style.display = 'none';
arrowEl.style.transform = 'rotate(-90deg)';
}
}
});
// Check for URL parameter to auto-load document or category
const urlParams = new URLSearchParams(window.location.search);
const docParam = urlParams.get('doc');
const categoryParam = urlParams.get('category');
// Priority 1: Load specific document by slug if provided
if (docParam) {
const doc = documents.find(d => d.slug === docParam);
if (doc) {
// Find and expand the category containing this document
const docCategory = categorizeDocument(doc);
if (docCategory) {
const categoryDocsEl = listEl.querySelector(`.category-docs[data-category="${docCategory}"]`);
const categoryArrowEl = listEl.querySelector(`.category-toggle[data-category="${docCategory}"] .category-arrow`);
if (categoryDocsEl) {
categoryDocsEl.style.display = 'block';
if (categoryArrowEl) {
categoryArrowEl.style.transform = 'rotate(0deg)';
}
}
}
// Load the requested document
loadDocument(docParam);
} else {
console.warn(`Document with slug "${docParam}" not found`);
}
}
// Priority 2: Load category if provided but no specific document
else if (categoryParam && grouped[categoryParam] && grouped[categoryParam].length > 0) {
// Expand the specified category
const categoryDocsEl = listEl.querySelector(`.category-docs[data-category="${categoryParam}"]`);
const categoryArrowEl = listEl.querySelector(`.category-toggle[data-category="${categoryParam}"] .category-arrow`);
if (categoryDocsEl) {
categoryDocsEl.style.display = 'block';
if (categoryArrowEl) {
categoryArrowEl.style.transform = 'rotate(0deg)';
}
}
// Load first document in the category
const firstDoc = grouped[categoryParam][0];
if (firstDoc) {
loadDocument(firstDoc.slug);
}
}
// Priority 3: Default behavior
else {
// Default: Auto-load first document in "Getting Started" category (order: 1)
const gettingStartedDocs = grouped['getting-started'] || [];
if (gettingStartedDocs.length > 0) {
// Load the first document (order: 1) if available
const firstDoc = gettingStartedDocs.find(d => d.order === 1);
if (firstDoc) {
loadDocument(firstDoc.slug);
} else {
loadDocument(gettingStartedDocs[0].slug);
}
} else if (documents.length > 0) {
// Fallback to first available document in any category
const firstCategory = sortedCategories.find(([catId]) => grouped[catId] && grouped[catId].length > 0);
if (firstCategory) {
loadDocument(grouped[firstCategory[0]][0].slug);
}
}
}
} catch (error) {
console.error('Error loading documents:', error);
document.getElementById('document-list').innerHTML =
'<div class="text-sm text-red-600">Error loading documents</div>';
}
}
// Load specific document
let isLoading = false;
async function loadDocument(slug, lang = null) {
// Prevent multiple simultaneous loads
if (isLoading) return;
// Use provided lang or detect from i18n system
const language = lang || detectLanguage();
try {
isLoading = true;
const t = getUITranslations(language);
// Show loading state
const contentEl = document.getElementById('document-content');
contentEl.innerHTML = `
<div class="text-center py-12">
<svg class="animate-spin h-8 w-8 text-blue-600 mx-auto mb-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-gray-600">${t.loadingDocument}</p>
</div>
`;
// Build API URL with language parameter
const apiUrl = language && language !== 'en'
? `/api/documents/${slug}?lang=${language}`
: `/api/documents/${slug}`;
const response = await fetch(apiUrl);
const data = await response.json();
if (!data.success) {
// If translation not available, fall back to English
if (response.status === 404 && language !== 'en') {
console.warn(`Translation not available for ${language}, falling back to English`);
const enResponse = await fetch(`/api/documents/${slug}`);
const enData = await enResponse.json();
if (enData.success) {
// Translation not available - falling back to English
// (console warning already logged above)
const fallbackData = enData;
fallbackData.document.language = 'en';
fallbackData.document.fallback = true;
// Use English version
currentDocument = fallbackData.document;
currentLanguage = 'en';
updateURL(slug, 'en');
// Continue with rendering
data.success = true;
data.document = fallbackData.document;
} else {
throw new Error(data.error || 'Failed to load document');
}
} else {
throw new Error(data.error || 'Failed to load document');
}
} else {
currentDocument = data.document;
currentLanguage = language;
updateURL(slug, language);
}
// Update active state
document.querySelectorAll('.doc-link').forEach(el => {
if (el.dataset.slug === slug) {
el.classList.add('bg-blue-100', 'text-blue-900');
} else {
el.classList.remove('bg-blue-100', 'text-blue-900');
}
});
// Render with card-based viewer if available and document has sections
if (documentCards && currentDocument.sections && currentDocument.sections.length > 0) {
documentCards.render(currentDocument);
} else {
// Fallback to traditional view with header
const hasToC = currentDocument.toc && currentDocument.toc.length > 0;
// Check if PDF is available and get PDF path
let pdfPath = null;
let hasPDF = false;
if (currentDocument.download_formats && currentDocument.download_formats.pdf) {
pdfPath = currentDocument.download_formats.pdf;
hasPDF = true;
}
let headerHTML = `
<div class="flex items-center justify-between mb-6 pb-4 border-b border-gray-200">
<h1 class="text-3xl font-bold text-gray-900">${currentDocument.title}</h1>
<div class="flex items-center gap-2">
${hasToC ? `
<button id="toc-button"
class="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded transition"
title="Table of Contents"
aria-label="Show table of contents">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h7"/>
</svg>
</button>
` : ''}
${hasPDF ? `
<a href="${pdfPath}"
target="_blank"
rel="noopener noreferrer"
class="p-2 text-gray-600 hover:text-blue-600 hover:bg-blue-50 rounded transition"
title="Download PDF"
aria-label="Download PDF">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</a>
` : ''}
</div>
</div>
`;
// Remove duplicate title H1 from content (it's already in header)
let contentHtml = currentDocument.content_html;
const firstH1Match = contentHtml.match(/<h1[^>]*>.*?<\/h1>/);
if (firstH1Match) {
contentHtml = contentHtml.replace(firstH1Match[0], '');
}
contentEl.innerHTML = headerHTML + `
<div class="prose max-w-none">
${contentHtml}
</div>
`;
}
// Add ToC button event listener (works for both card and traditional views)
setTimeout(() => {
const tocButton = document.getElementById('toc-button');
if (tocButton) {
tocButton.addEventListener('click', () => openToCModal());
}
}, 100);
// Mobile navigation: Add document-active class to show document view
document.body.classList.add('document-active');
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
} catch (error) {
console.error('Error loading document:', error);
const t = getUITranslations(currentLanguage);
document.getElementById('document-content').innerHTML = `
<div class="text-center py-12">
<svg class="h-12 w-12 text-red-400 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h3 class="text-lg font-medium text-gray-900 mb-2">${t.errorLoadingDoc}</h3>
<p class="text-sm text-gray-600">${error.message}</p>
</div>
`;
} finally {
isLoading = false;
}
}
// Open ToC modal
function openToCModal() {
if (!currentDocument || !currentDocument.toc || currentDocument.toc.length === 0) {
return;
}
const modal = document.getElementById('toc-modal');
if (!modal) return;
// Render ToC content
const tocContent = document.getElementById('toc-modal-content');
const tocHTML = currentDocument.toc
.filter(item => item.level <= 3) // Only show H1, H2, H3
.map(item => {
return `
<a href="#${item.slug}"
class="block py-2 px-3 text-gray-700 hover:bg-blue-50 hover:text-blue-700 rounded transition toc-link toc-indent-${item.level}"
data-slug="${item.slug}">
${item.title}
</a>
`;
}).join('');
tocContent.innerHTML = tocHTML;
// Show modal
modal.classList.add('show');
// Prevent body scroll and reset modal content scroll
document.body.style.overflow = 'hidden';
tocContent.scrollTop = 0;
// Add event listeners to ToC links
tocContent.querySelectorAll('.toc-link').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const targetId = link.getAttribute('href').substring(1);
const targetEl = document.getElementById(targetId);
if (targetEl) {
closeToCModal();
setTimeout(() => {
targetEl.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 200);
}
});
});
}
// Close ToC modal
function closeToCModal() {
const modal = document.getElementById('toc-modal');
if (modal) {
modal.classList.remove('show');
document.body.style.overflow = '';
}
}
// Initialize
loadDocuments();
// Add ESC key listener for closing modal
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
closeToCModal();
}
});
// Add close button listener for ToC modal (script loads after DOM, so elements exist)
const closeButton = document.getElementById('toc-close-button');
if (closeButton) {
closeButton.addEventListener('click', closeToCModal);
}
// Click outside modal to close
const modal = document.getElementById('toc-modal');
if (modal) {
modal.addEventListener('click', function(e) {
if (e.target === this) {
closeToCModal();
}
});
}
// Mobile navigation: Back to documents button
const backButton = document.getElementById('back-to-docs-btn');
if (backButton) {
backButton.addEventListener('click', function() {
// Remove document-active class to show sidebar
document.body.classList.remove('document-active');
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
});
}
// Sidebar filter event listeners
const filterTypeSelect = document.getElementById('filter-doc-type');
const filterAudienceSelect = document.getElementById('filter-doc-audience');
const clearSidebarFilters = document.getElementById('clear-sidebar-filters');
function onFilterChange() {
if (filterTypeSelect) activeFilters.document_type = filterTypeSelect.value;
if (filterAudienceSelect) activeFilters.audience = filterAudienceSelect.value;
// Update URL with filter params
const url = new URL(window.location);
if (activeFilters.document_type) {
url.searchParams.set('type', activeFilters.document_type);
} else {
url.searchParams.delete('type');
}
if (activeFilters.audience) {
url.searchParams.set('audience', activeFilters.audience);
} else {
url.searchParams.delete('audience');
}
window.history.replaceState({}, '', url);
renderFilteredDocuments();
}
if (filterTypeSelect) filterTypeSelect.addEventListener('change', onFilterChange);
if (filterAudienceSelect) filterAudienceSelect.addEventListener('change', onFilterChange);
if (clearSidebarFilters) {
clearSidebarFilters.addEventListener('click', function() {
activeFilters.document_type = '';
activeFilters.audience = '';
if (filterTypeSelect) filterTypeSelect.value = '';
if (filterAudienceSelect) filterAudienceSelect.value = '';
// Clear URL filter params
const url = new URL(window.location);
url.searchParams.delete('type');
url.searchParams.delete('audience');
window.history.replaceState({}, '', url);
renderFilteredDocuments();
});
}