Improve article TOC, sticky header, and cross-language anchors #1

Merged
popovskik merged 1 commits from kristijan-dev into develop 2026-07-28 08:14:05 +00:00
3 changed files with 175 additions and 57 deletions

View File

@ -15,7 +15,9 @@
/* 1. Glavni kontejner za glavo */
.dropdown-header-navigation {
position: absolute;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 80px;
background: #FFFFFF;

View File

@ -51,15 +51,36 @@
width: 288px;
flex-shrink: 0;
position: sticky;
top: 120px;
height: calc(100vh - 240px);
top: 112px;
/* max-height (not fixed height) so short lists size to content and never clip;
the list only scrolls when the viewport is genuinely too short. */
max-height: calc(100vh - 140px);
display: flex;
flex-direction: column;
}
/* Article title above the table of contents, so the reader always knows
which article they are reading. Injected by main.js from the page <h1>. */
.article-sidebar-title {
flex-shrink: 0;
margin: 0 0 16px 0;
padding: 0 0 16px 20px;
font-size: 16px;
font-weight: 600;
line-height: 24px;
color: #101828;
border-bottom: 1px solid #EAECF0;
}
.scrollspy-nav {
height: 100%;
/* Fill the remaining sidebar height and scroll internally when needed.
min-height:0 lets a flex child actually shrink so overflow works. */
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
align-items: center;
/* No flex centering here: centering inside a scroll container pushed the first
item out of reach at 100% zoom / short viewports. Content now flows from the top. */
padding: 4px 0;
}
.scrollspy-nav ul {
@ -68,15 +89,16 @@
margin: 0;
display: flex;
flex-direction: column;
gap: 40px;
gap: 8px;
border-left: 2px solid #EAECF0;
}
.scrollspy-nav li a {
display: block;
padding: 12px 24px;
padding: 8px 20px;
text-decoration: none;
font-size: 16px;
font-size: 14px;
line-height: 20px;
font-weight: 500;
color: #667085;
border-left: 2px solid transparent;

172
main.js
View File

@ -275,39 +275,134 @@ document.addEventListener('DOMContentLoaded', function() {
try {
const articlePage = document.querySelector('.article-page');
if (articlePage) {
const scrollspyNav = document.querySelector('.scrollspy-nav');
const mobileHeader = document.querySelector('.mobile-article-header');
const articleContainer = articlePage.querySelector('.article-container');
const articleBody = articlePage.querySelector('.article-body');
const headings = articleBody ? Array.from(articleBody.querySelectorAll('h2')) : [];
const articleTitleEl = articlePage.querySelector('.article-header h1');
const articleTitle = articleTitleEl ? articleTitleEl.textContent.trim()
: (document.title || '').trim();
// Label for a heading in the table of contents. An optional
// data-nav-title attribute lets an article override the full <h2>
// text with a shorter label; otherwise we use the heading text.
const navLabel = (heading) =>
(heading.getAttribute('data-nav-title') || heading.textContent).trim();
// --- 5a. Zagotovi, da ima vsak naslov enoličen id (za sidra) ---
// Transliterate then slugify so anchors are readable in every language:
// Slovenian diacritics (č/š/ž/…) and Macedonian Cyrillic map to ASCII.
// Anything still unmapped is dropped and falls back to section-N below.
const TRANSLIT = {
// Slovenian / Latin diacritics
'č':'c','ć':'c','š':'s','ž':'z','đ':'dj',
'á':'a','à':'a','â':'a','ä':'a','ã':'a','é':'e','è':'e','ê':'e','ë':'e',
'í':'i','ì':'i','î':'i','ï':'i','ó':'o','ò':'o','ô':'o','ö':'o','õ':'o',
'ú':'u','ù':'u','û':'u','ü':'u','ñ':'n','ç':'c','ß':'ss',
// Macedonian Cyrillic
'а':'a','б':'b','в':'v','г':'g','д':'d','ѓ':'gj','е':'e','ж':'zh','з':'z',
'ѕ':'dz','и':'i','ј':'j','к':'k','л':'l','љ':'lj','м':'m','н':'n','њ':'nj',
'о':'o','п':'p','р':'r','с':'s','т':'t','ќ':'kj','у':'u','ф':'f','х':'h',
'ц':'c','ч':'ch','џ':'dj','ш':'sh'
};
const usedIds = new Set();
const transliterate = (text) =>
text.replace(/[^\x00-\x7F]/g, (ch) => (ch in TRANSLIT ? TRANSLIT[ch] : ''));
const slugify = (text) => transliterate(text.toLowerCase())
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
headings.forEach((heading, i) => {
let id = heading.id || slugify(heading.textContent);
if (id.length < 2) id = 'section-' + (i + 1);
let unique = id, n = 2;
while (usedIds.has(unique)) { unique = id + '-' + (n++); }
usedIds.add(unique);
heading.id = unique;
});
// --- 5b. Zagotovi postavitveni ovoj in stransko vrstico (Desktop) ---
// Older article pages (SI/MK and some EN) ship without the TOC
// scaffolding, so we build it here from the page's own headings.
let layoutContainer = articlePage.querySelector('.article-layout-container');
if (!layoutContainer && articleContainer) {
layoutContainer = document.createElement('div');
layoutContainer.className = 'article-layout-container';
articleContainer.parentNode.insertBefore(layoutContainer, articleContainer);
layoutContainer.appendChild(articleContainer);
}
let sidebar = articlePage.querySelector('.article-sidebar');
if (!sidebar && layoutContainer && headings.length > 0) {
sidebar = document.createElement('aside');
sidebar.className = 'article-sidebar';
sidebar.innerHTML = '<nav class="scrollspy-nav"><ul></ul></nav>';
layoutContainer.insertBefore(sidebar, layoutContainer.firstChild);
}
const scrollspyNav = sidebar ? sidebar.querySelector('.scrollspy-nav') : null;
const desktopUl = scrollspyNav ? scrollspyNav.querySelector('ul') : null;
// --- 5c. Napolni namizni seznam, če je prazen (ohrani ročno napisanega) ---
if (desktopUl && desktopUl.children.length === 0 && headings.length > 0) {
headings.forEach(heading => {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = '#' + heading.id;
a.textContent = navLabel(heading);
li.appendChild(a);
desktopUl.appendChild(li);
});
}
// --- 5d. Naslov članka nad kazalom (Desktop) ---
if (sidebar && !sidebar.querySelector('.article-sidebar-title') && articleTitle) {
const titleEl = document.createElement('p');
titleEl.className = 'article-sidebar-title';
titleEl.textContent = articleTitle;
sidebar.insertBefore(titleEl, sidebar.firstChild);
}
// --- 5e. Zagotovi lepljivo mobilno glavo z naslovom + progress bar ---
let mobileHeader = articlePage.querySelector('.mobile-article-header');
if (!mobileHeader && headings.length > 0) {
mobileHeader = document.createElement('div');
mobileHeader.className = 'mobile-article-header';
mobileHeader.innerHTML =
'<span id="mobile-article-title"></span>' +
'<i class="fas fa-chevron-down"></i>' +
'<div class="progress-bar-container"><div class="progress-bar"></div></div>' +
'<div class="mobile-scrollspy-dropdown"><ul id="mobile-scrollspy-links"></ul></div>';
articlePage.insertBefore(mobileHeader, articlePage.firstChild);
}
const mobileTitle = document.getElementById('mobile-article-title');
const mobileDropdown = document.querySelector('.mobile-scrollspy-dropdown');
if (mobileTitle && !mobileTitle.textContent.trim()) {
mobileTitle.textContent = articleTitle; // known at load; scrollspy refines it
}
const mobileDropdown = articlePage.querySelector('.mobile-scrollspy-dropdown');
const mobileLinksContainer = document.getElementById('mobile-scrollspy-links');
const progressBar = document.querySelector('.progress-bar');
const progressBar = articlePage.querySelector('.progress-bar');
const desktopNavLinks = desktopUl ? Array.from(desktopUl.querySelectorAll('a')) : [];
const headings = Array.from(articlePage.querySelectorAll('.article-body h2'));
const desktopNavLinks = scrollspyNav ? Array.from(scrollspyNav.querySelectorAll('a')) : [];
// --- Dinamično ustvarjanje mobilnega spustnega seznama ---
if (headings.length > 0 && mobileLinksContainer) {
// --- 5f. Napolni mobilni spustni seznam ---
if (headings.length > 0 && mobileLinksContainer && mobileLinksContainer.children.length === 0) {
headings.forEach(heading => {
const listItem = document.createElement('li');
const link = document.createElement('a');
link.href = `#${heading.id}`;
link.textContent = heading.textContent;
link.href = '#' + heading.id;
link.textContent = navLabel(heading);
listItem.appendChild(link);
mobileLinksContainer.appendChild(listItem);
});
}
// --- Logika za odpiranje/zapiranje mobilnega spustnega seznama ---
// --- 5g. Odpiranje/zapiranje mobilnega spustnega seznama ---
if (mobileHeader && mobileDropdown) {
mobileHeader.addEventListener('click', (e) => {
// Preprečimo, da se meni zapre, če kliknemo na povezavo znotraj njega
if (!e.target.closest('a')) {
mobileHeader.classList.toggle('open');
mobileDropdown.classList.toggle('open');
}
});
// Dodamo dogodek za zapiranje menija ob kliku na povezavo
mobileDropdown.querySelectorAll('a').forEach(link => {
link.addEventListener('click', () => {
mobileHeader.classList.remove('open');
@ -316,56 +411,55 @@ document.addEventListener('DOMContentLoaded', function() {
});
}
// --- Logika za Progress Bar ---
// --- 5h. Progress Bar ---
function updateProgressBar() {
if (!progressBar) return;
const scrollableHeight = document.documentElement.scrollHeight - window.innerHeight;
const scrollTop = window.scrollY;
if (scrollableHeight > 0) {
const progress = (scrollTop / scrollableHeight) * 100;
progressBar.style.width = `${progress}%`;
} else {
progressBar.style.width = '0%';
}
progressBar.style.width = scrollableHeight > 0
? `${(scrollTop / scrollableHeight) * 100}%`
: '0%';
}
// --- Logika za Scrollspy ---
// --- 5i. Scrollspy (posodobi navigacijo, naslov in URL sidro) ---
if (headings.length > 0) {
let activeId = null;
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const id = entry.target.getAttribute('id');
const desktopNavLink = desktopNavLinks.find(link => link.getAttribute('href') === `#${id}`);
const mobileNavLink = mobileLinksContainer ? mobileLinksContainer.querySelector(`a[href="#${id}"]`) : null;
if (!entry.isIntersecting) return;
const id = entry.target.id;
if (id === activeId) return;
activeId = id;
if (entry.isIntersecting) {
// Posodobi namizno navigacijo
// Namizna navigacija
const desktopNavLink = desktopNavLinks.find(link => link.getAttribute('href') === `#${id}`);
if (desktopNavLink) {
desktopNavLinks.forEach(link => link.classList.remove('active'));
desktopNavLink.classList.add('active');
}
// Posodobi mobilni naslov in aktivno povezavo v spustnem seznamu
if (mobileTitle) {
mobileTitle.textContent = entry.target.textContent;
}
// Mobilni naslov + aktivna povezava
if (mobileTitle) mobileTitle.textContent = navLabel(entry.target);
if (mobileLinksContainer) {
const mobileNavLink = mobileLinksContainer.querySelector(`a[href="#${id}"]`);
if (mobileNavLink) {
mobileLinksContainer.querySelectorAll('a').forEach(link => link.classList.remove('active'));
mobileNavLink.classList.add('active');
}
}
// Posodobi URL sidro brez skoka in brez polnjenja zgodovine
if (window.history && history.replaceState) {
history.replaceState(null, '', '#' + id);
}
});
}, {
rootMargin: "-100px 0px -50% 0px" // Sproži, ko je naslov v zgornjem delu zaslona
rootMargin: "-100px 0px -55% 0px" // Sproži, ko je naslov v zgornjem delu zaslona
});
headings.forEach(heading => {
observer.observe(heading);
});
headings.forEach(heading => observer.observe(heading));
}
// Dodaj poslušalca dogodkov za vrstico napredka
window.addEventListener('scroll', updateProgressBar);
updateProgressBar(); // Klic ob nalaganju strani
updateProgressBar();
}
} catch (error) {
console.error('Error in Article Scrollspy/Progress Bar setup:', error);