diff --git a/data/db.sqlite3 b/data/db.sqlite3
index 093307b..5a19c3a 100644
Binary files a/data/db.sqlite3 and b/data/db.sqlite3 differ
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 %}
+
+
+
+
+
+
+
+
+
+
+
+
@@ -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);
+ });
+ })();