From f222ee0fe998bbabbaa610e44ba22877a1037b81 Mon Sep 17 00:00:00 2001 From: Junwei Zhao Date: Mon, 30 Mar 2026 17:26:32 +1100 Subject: [PATCH] Add toc --- data/db.sqlite3 | Bin 479232 -> 479232 bytes links/templates/links/post_detail.html | 306 ++++++++++++++++++++++++- links/templates/links/public_post.html | 277 ++++++++++++++++++++-- links/templatetags/markdown_extras.py | 2 + 4 files changed, 561 insertions(+), 24 deletions(-) diff --git a/data/db.sqlite3 b/data/db.sqlite3 index 093307bb20ae5281d8e4942665a830f5db394cfd..5a19c3afac694f48f719647a7abd9dab02db4c52 100644 GIT binary patch delta 2618 zcmb7GO>bL86io;T5H)hyuql^JcI#+{j0 zVHAQP?2m#qyh(d(S=hoIBn>IKO{z z{_(k`i!(DbDV}S1UjMGRF?;c+&TrM(vtJi$kBYTfJg*!UYjeNvzCB-fjy*GR;qStq zg+B@}3eV@inqQdvb@tNC){{p4ox`pDxu=DBY;lFd7vIjEJ3Idxz6>6nIhe!qDxUen zGY1QQ6&7X}`ZEjtpBwd07P2RAoqc2G+|x>Wh}7Gr5>tW!#)42k1mNe`tFPLgodrNyPif&=Spty&KjMOjZt6VEmgMKo2=pWAss0Kd zzAw9N9egI8WfV3LvER6m+x9i}YDj4`#Ha4MkfUC?DD#PA?hMut;RHXRPN0hc#p0ewz3vK>+@dui+? zFp89uvBEMm(H$ce!6MxMAXYL#6I3Xx9*0hVv9BJ2l(EiI5)^6c60n}zW0^jXB4)a_ znOSfG;l|hbMWs?XZ6OxLDF^N7QiNa3*~kL|(|hBPxSau;T(um(aZc4}g%I`r(D z?~md%%G9ZiWcT#jtrddw8G3#+_n{XpEIF}ZE_yb>{l+aMb+V6P!1Ke`l^s*f>z*4( zLO?_4toDQzxrAn{)9{wjiCd}CcJH_(Z0g1kJ3I^bDV^e+5xpMz^s)n~E1?>=mz98i zVuEA_4T7AcDf?UiH9$Jyv>E5v+wB%%-3ys`Q-fCjQ1I-RrUFCvD;VB92J}8Q;)H;9}i}ZT~I)m!x$sRq*@K z4-pnE7x6BYN{#%IPc08(pY3|7D(*wSpK+2z( zmOUOmsAF<|<>(;%z|$jFhLtWIo{Ye?^02|^?coO%2(#7I`s$TReXX)qFH`->=B4$` j^{dtTdcA&m?Rd$rT;04}udZEPU8`?=@#T-dEIj@Px|w3~ delta 432 zcmZozAltA&c7imo1_J{_DG*x&G3P`Lb4HDg3CHyq`I|NL+coqVftYE#hCcJ102vnk zH$XKECH%knzwv+Mf5Uc&wTYFD`52=igVSV31EuXw6PQ==Z-3LzEG@vw0aTgO&a;3S zh*^M`bvw@jwjcaroDA^{yl;3ean0hG$ZpDVm8q1mj3FMV-BqMX#7>C8z0tQ(me6h2W%{iLN&I647r(&x(JEs6CSd?6^gYHWYCvwuSAG%?|Z~{fN}bqZ%i@M zr+r~E6mE&NMprW(s78AG_b*IFOrlMZRzeJoy5&vc2s3!MJA7wyXB2B;vlM1%;%`iB z$^}a*axggZ@=ka3XA<7N=?BvTpbAq9VTMNgM$MLBBo*6N{bEvL5@`}Q6J*HOZA?d! znqI@lEIs}2AEwu$ErO;(4Asr7O{K{4V8d@qGEZlmzMhqtbNZ(LOg_sFuuT8}$kuXX diff --git a/links/templates/links/post_detail.html b/links/templates/links/post_detail.html index 32d45b5..81bab6b 100644 --- a/links/templates/links/post_detail.html +++ b/links/templates/links/post_detail.html @@ -323,6 +323,130 @@ border-radius: 0.5rem; padding: 1rem; } + + /* ===== Table of Contents Layout ===== */ + #post-toc-layout { + max-width: 80rem; /* max-w-6xl equivalent */ + margin: 0.5rem auto; + padding: 0.5rem; + } + #toc-sidebar { + display: none; /* hidden by default (mobile) */ + } + @media (min-width: 1024px) { + #post-toc-layout { + display: flex; + align-items: stretch; /* sidebar stretches to match content height — required for sticky */ + gap: 1.5rem; + } + #toc-sidebar { + display: block; + width: 16rem; + flex-shrink: 0; + } + #toc-sidebar-inner { + position: sticky; + top: 5rem; /* sits just below fixed navbar (~64px) */ + max-height: calc(100vh - 5.5rem); + overflow-y: auto; + } + #post-main-content { + flex: 1 1 0%; + min-width: 0; + } + #toc-mobile-btn { + display: none !important; + } + } + #toc-sidebar-inner > div { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + padding: 1rem; + } + + #toc-sidebar .toc-link { + display: block; + font-size: 0.8rem; + line-height: 1.6; + color: #6b7280; + text-decoration: none; + padding: 0.2rem 0.5rem; + border-left: 2px solid transparent; + transition: color 0.15s, border-color 0.15s, background-color 0.15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + border-radius: 0 4px 4px 0; + } + #toc-sidebar .toc-link:hover { color: #1d4ed8; background-color: #eff6ff; } + #toc-sidebar .toc-link.toc-active { + color: #1d4ed8; + border-left-color: #1d4ed8; + font-weight: 500; + background-color: #eff6ff; + } + #toc-sidebar .toc-h2 { font-weight: 500; color: #374151; } + #toc-sidebar .toc-h3, + #toc-sidebar .toc-h4 { font-size: 0.775rem; } + + /* Mobile TOC button */ + #toc-mobile-btn { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 40; + background: #2563eb; + color: #fff; + border: none; + border-radius: 9999px; + width: 3rem; + height: 3rem; + box-shadow: 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.1); + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + transition: background-color 0.15s; + } + #toc-mobile-btn:hover { background: #1d4ed8; } + + /* Mobile drawer */ + #toc-mobile-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 40; + } + #toc-mobile-drawer { + position: fixed; + top: 0; + left: 0; + height: 100%; + width: 18rem; + max-width: 80vw; + background: #fff; + z-index: 50; + box-shadow: 0 25px 50px -12px rgba(0,0,0,.25); + overflow-y: auto; + transform: translateX(-100%); + transition: transform 0.3s ease; + } + #toc-nav-mobile .toc-link { + display: block; + font-size: 0.875rem; + line-height: 1.7; + color: #374151; + text-decoration: none; + padding: 0.3rem 0.5rem; + border-left: 2px solid transparent; + transition: color 0.15s, border-color 0.15s; + border-radius: 0 4px 4px 0; + } + #toc-nav-mobile .toc-link:hover { color: #1d4ed8; background-color: #eff6ff; } + #toc-nav-mobile .toc-link.toc-active { color: #1d4ed8; border-left-color: #1d4ed8; font-weight: 500; } {% endblock %} @@ -357,8 +481,21 @@ - -
+ +
+ + + + + +

{{ post.title }}

@@ -555,6 +692,31 @@ {% trans "Append" %}
+
+
+ + + + + + + + +
+
+
+

{% trans "Contents" %}

+ +
+
{% endblock %} @@ -952,5 +1114,145 @@ function regenerateWithVoice() { regenerateBtn.textContent = originalText; }, 2000); } + +// ============================================================ +// Table of Contents +// ============================================================ +(function() { + function buildTOC() { + const article = document.querySelector('article.prose'); + if (!article) return; + + const headings = article.querySelectorAll('h2, h3, h4'); + if (headings.length < 2) { + // Not enough headings - hide TOC sidebar and mobile button + const sidebar = document.getElementById('toc-sidebar'); + const mobileBtn = document.getElementById('toc-mobile-btn'); + if (sidebar) sidebar.style.display = 'none'; + if (mobileBtn) mobileBtn.style.display = 'none'; + return; + } + + function createLinks(navEl) { + navEl.innerHTML = ''; + headings.forEach(function(heading) { + // Ensure heading has an id (toc extension adds these, but fallback just in case) + if (!heading.id) { + heading.id = heading.textContent.trim() + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + } + const level = parseInt(heading.tagName[1]); // 2, 3, or 4 + const link = document.createElement('a'); + link.href = '#' + heading.id; + link.textContent = heading.textContent; + link.classList.add('toc-link', 'toc-h' + level); + link.setAttribute('data-heading-id', heading.id); + + // Visual indentation by heading level + link.style.paddingLeft = ((level - 2) * 12 + 8) + 'px'; + + link.addEventListener('click', function(e) { + e.preventDefault(); + closeMobileDrawer(); + const target = document.getElementById(heading.id); + if (target) { + const nav = document.getElementById('main-nav'); + const navH = nav ? nav.offsetHeight : 70; + const y = target.getBoundingClientRect().top + window.scrollY - navH - 16; + window.scrollTo({ top: y, behavior: 'smooth' }); + } + }); + navEl.appendChild(link); + }); + } + + const tocNav = document.getElementById('toc-nav'); + const tocNavMobile = document.getElementById('toc-nav-mobile'); + if (tocNav) createLinks(tocNav); + if (tocNavMobile) createLinks(tocNavMobile); + + // Scroll-spy: find the heading whose top is closest above the viewport threshold + var _rafPending = false; + function onScroll() { + if (_rafPending) return; + _rafPending = true; + requestAnimationFrame(function() { + _rafPending = false; + var nav = document.getElementById('main-nav'); + var navH = nav ? nav.offsetHeight : 64; + var threshold = navH + 24; // a bit below the navbar + var activeId = null; + // Walk headings in reverse: pick the last one whose top <= threshold + for (var i = headings.length - 1; i >= 0; i--) { + var rect = headings[i].getBoundingClientRect(); + if (rect.top <= threshold) { + activeId = headings[i].id; + break; + } + } + // If none is above threshold, highlight the first heading + if (!activeId && headings.length > 0) activeId = headings[0].id; + highlightTOC(activeId); + // Auto-scroll the active TOC link into view within the sidebar + var activeLinkInSidebar = document.querySelector('#toc-nav .toc-link.toc-active'); + if (activeLinkInSidebar) { + var sidebarInner = document.getElementById('toc-sidebar-inner'); + if (sidebarInner) { + var linkTop = activeLinkInSidebar.offsetTop; + var linkH = activeLinkInSidebar.offsetHeight; + var scrollTop = sidebarInner.scrollTop; + var sidebarH = sidebarInner.clientHeight; + if (linkTop < scrollTop) { + sidebarInner.scrollTop = linkTop - 8; + } else if (linkTop + linkH > scrollTop + sidebarH) { + sidebarInner.scrollTop = linkTop + linkH - sidebarH + 8; + } + } + } + }); + } + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); // run once on load + + function highlightTOC(id) { + document.querySelectorAll('.toc-link').forEach(function(a) { + if (id && a.getAttribute('data-heading-id') === id) { + a.classList.add('toc-active'); + } else { + a.classList.remove('toc-active'); + } + }); + } + } + + function openMobileDrawer() { + const drawer = document.getElementById('toc-mobile-drawer'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (drawer) drawer.style.transform = 'translateX(0)'; + if (backdrop) backdrop.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + } + + function closeMobileDrawer() { + const drawer = document.getElementById('toc-mobile-drawer'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (drawer) drawer.style.transform = 'translateX(-100%)'; + if (backdrop) backdrop.classList.add('hidden'); + document.body.style.overflow = ''; + } + + document.addEventListener('DOMContentLoaded', function() { + buildTOC(); + const mobileBtn = document.getElementById('toc-mobile-btn'); + const mobileClose = document.getElementById('toc-mobile-close'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (mobileBtn) mobileBtn.addEventListener('click', openMobileDrawer); + if (mobileClose) mobileClose.addEventListener('click', closeMobileDrawer); + if (backdrop) backdrop.addEventListener('click', closeMobileDrawer); + }); +})(); {% endblock %} diff --git a/links/templates/links/public_post.html b/links/templates/links/public_post.html index 1c8bdb7..5d99aba 100644 --- a/links/templates/links/public_post.html +++ b/links/templates/links/public_post.html @@ -75,34 +75,143 @@ .think-icon.expanded { transform: rotate(180deg); } + + /* ===== Table of Contents Layout ===== */ + #post-toc-layout { + max-width: 80rem; + margin: 2.5rem auto 0; + padding: 0.5rem 1rem; + } + #toc-sidebar { display: none; } + @media (min-width: 1024px) { + #post-toc-layout { display: flex; align-items: stretch; gap: 1.5rem; } + #toc-sidebar { display: block; width: 16rem; flex-shrink: 0; } + #toc-sidebar-inner { position: sticky; top: 1.5rem; max-height: calc(100vh - 4rem); overflow-y: auto; } + #post-main-content { flex: 1 1 0%; min-width: 0; } + #toc-mobile-btn { display: none !important; } + } + #toc-sidebar-inner > div { + background: #fff; + border: 1px solid #e5e7eb; + border-radius: 0.5rem; + padding: 1rem; + box-shadow: 0 1px 3px 0 rgba(0,0,0,.07); + } + #toc-sidebar .toc-link { + display: block; + font-size: 0.8rem; + line-height: 1.6; + color: #6b7280; + text-decoration: none; + padding: 0.2rem 0.5rem; + border-left: 2px solid transparent; + transition: color 0.15s, border-color 0.15s, background-color 0.15s; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + border-radius: 0 4px 4px 0; + } + #toc-sidebar .toc-link:hover { color: #1d4ed8; background-color: #eff6ff; } + #toc-sidebar .toc-link.toc-active { color: #1d4ed8; border-left-color: #1d4ed8; font-weight: 500; background-color: #eff6ff; } + #toc-sidebar .toc-h2 { font-weight: 500; color: #374151; } + #toc-sidebar .toc-h3, #toc-sidebar .toc-h4 { font-size: 0.775rem; } + + /* Mobile button */ + #toc-mobile-btn { + position: fixed; bottom: 1.5rem; right: 1.5rem; z-index: 40; + background: #2563eb; color: #fff; border: none; border-radius: 9999px; + width: 3rem; height: 3rem; cursor: pointer; + box-shadow: 0 10px 15px -3px rgba(0,0,0,.1), 0 4px 6px -4px rgba(0,0,0,.1); + display: flex; align-items: center; justify-content: center; + transition: background-color 0.15s; + } + #toc-mobile-btn:hover { background: #1d4ed8; } + #toc-mobile-backdrop { + display: none; position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 40; + } + #toc-mobile-drawer { + position: fixed; top: 0; left: 0; height: 100%; + width: 18rem; max-width: 80vw; background: #fff; z-index: 50; + box-shadow: 0 25px 50px -12px rgba(0,0,0,.25); overflow-y: auto; + transform: translateX(-100%); transition: transform 0.3s ease; + } + #toc-nav-mobile .toc-link { + display: block; font-size: 0.875rem; line-height: 1.7; + color: #374151; text-decoration: none; padding: 0.3rem 0.5rem; + border-left: 2px solid transparent; transition: color 0.15s, border-color 0.15s; + border-radius: 0 4px 4px 0; + } + #toc-nav-mobile .toc-link:hover { color: #1d4ed8; background-color: #eff6ff; } + #toc-nav-mobile .toc-link.toc-active { color: #1d4ed8; border-left-color: #1d4ed8; font-weight: 500; } -
- -
-
-
-

{{ post.title }}

-
- {{ post.created_at|date:"Y-m-d H:i" }} -
+ +
+ + +
+ - -
- {% markdown_with_tasks post.content post.task_states %} + +
+ +
+
+
+

{{ post.title }}

+
+ {{ post.created_at|date:"Y-m-d H:i" }} +
+
+
+ {% if post.tags.exists %} +
+ {% for tag in post.tags.all %} + + {{ tag.name }} + + {% endfor %} +
+ {% endif %} +
+ + +
+ {% markdown_with_tasks post.content post.task_states %} +
+
+
+ + + + + + + + +
+
+
+

Contents

+ +
+
@@ -146,6 +255,130 @@ }); }); }); + + // ============================================================ + // Table of Contents + // ============================================================ + (function() { + function buildTOC() { + const proseEl = document.querySelector('.prose'); + if (!proseEl) return; + + const headings = proseEl.querySelectorAll('h2, h3, h4'); + if (headings.length < 2) { + const sidebar = document.getElementById('toc-sidebar'); + const mobileBtn = document.getElementById('toc-mobile-btn'); + if (sidebar) sidebar.style.display = 'none'; + if (mobileBtn) mobileBtn.style.display = 'none'; + return; + } + + function createLinks(navEl) { + navEl.innerHTML = ''; + headings.forEach(function(heading) { + if (!heading.id) { + heading.id = heading.textContent.trim() + .toLowerCase() + .replace(/[^\w\s-]/g, '') + .replace(/\s+/g, '-') + .replace(/-+/g, '-'); + } + const level = parseInt(heading.tagName[1]); + const link = document.createElement('a'); + link.href = '#' + heading.id; + link.textContent = heading.textContent; + link.classList.add('toc-link', 'toc-h' + level); + link.setAttribute('data-heading-id', heading.id); + link.style.paddingLeft = ((level - 2) * 12 + 8) + 'px'; + link.addEventListener('click', function(e) { + e.preventDefault(); + closeMobileDrawer(); + const target = document.getElementById(heading.id); + if (target) { + const y = target.getBoundingClientRect().top + window.scrollY - 24; + window.scrollTo({ top: y, behavior: 'smooth' }); + } + }); + navEl.appendChild(link); + }); + } + + const tocNav = document.getElementById('toc-nav'); + const tocNavMobile = document.getElementById('toc-nav-mobile'); + if (tocNav) createLinks(tocNav); + if (tocNavMobile) createLinks(tocNavMobile); + + // Scroll-spy: find heading closest above the viewport threshold + var _rafPending = false; + function onScroll() { + if (_rafPending) return; + _rafPending = true; + requestAnimationFrame(function() { + _rafPending = false; + var threshold = 40; + var activeId = null; + for (var i = headings.length - 1; i >= 0; i--) { + if (headings[i].getBoundingClientRect().top <= threshold) { + activeId = headings[i].id; + break; + } + } + if (!activeId && headings.length > 0) activeId = headings[0].id; + highlightTOC(activeId); + var activeLinkInSidebar = document.querySelector('#toc-nav .toc-link.toc-active'); + if (activeLinkInSidebar) { + var sidebarInner = document.getElementById('toc-sidebar-inner'); + if (sidebarInner) { + var linkTop = activeLinkInSidebar.offsetTop; + var linkH = activeLinkInSidebar.offsetHeight; + var scrollTop = sidebarInner.scrollTop; + var sidebarH = sidebarInner.clientHeight; + if (linkTop < scrollTop) sidebarInner.scrollTop = linkTop - 8; + else if (linkTop + linkH > scrollTop + sidebarH) sidebarInner.scrollTop = linkTop + linkH - sidebarH + 8; + } + } + }); + } + window.addEventListener('scroll', onScroll, { passive: true }); + onScroll(); + + function highlightTOC(id) { + document.querySelectorAll('.toc-link').forEach(function(a) { + if (id && a.getAttribute('data-heading-id') === id) { + a.classList.add('toc-active'); + } else { + a.classList.remove('toc-active'); + } + }); + } + } + + function openMobileDrawer() { + const drawer = document.getElementById('toc-mobile-drawer'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (drawer) drawer.style.transform = 'translateX(0)'; + if (backdrop) backdrop.classList.remove('hidden'); + document.body.style.overflow = 'hidden'; + } + + function closeMobileDrawer() { + const drawer = document.getElementById('toc-mobile-drawer'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (drawer) drawer.style.transform = 'translateX(-100%)'; + if (backdrop) backdrop.classList.add('hidden'); + document.body.style.overflow = ''; + } + + document.addEventListener('DOMContentLoaded', function() { + buildTOC(); + const mobileBtn = document.getElementById('toc-mobile-btn'); + const mobileClose = document.getElementById('toc-mobile-close'); + const backdrop = document.getElementById('toc-mobile-backdrop'); + if (mobileBtn) mobileBtn.addEventListener('click', openMobileDrawer); + if (mobileClose) mobileClose.addEventListener('click', closeMobileDrawer); + if (backdrop) backdrop.addEventListener('click', closeMobileDrawer); + }); + })(); diff --git a/links/templatetags/markdown_extras.py b/links/templatetags/markdown_extras.py index 76f8155..ce66158 100644 --- a/links/templatetags/markdown_extras.py +++ b/links/templatetags/markdown_extras.py @@ -17,6 +17,7 @@ def markdown(value): return mark_safe(md.markdown(value, extensions=['markdown.extensions.fenced_code', 'markdown.extensions.tables', 'markdown.extensions.nl2br', + 'markdown.extensions.toc', think_markdown.ThinkExtension()])) @@ -37,6 +38,7 @@ def markdown_with_tasks(content, task_states=None): 'markdown.extensions.fenced_code', 'markdown.extensions.tables', 'markdown.extensions.nl2br', + 'markdown.extensions.toc', think_markdown.ThinkExtension(), tasklist_markdown.TaskListExtension(task_states=task_states) ]