This commit is contained in:
2026-03-30 17:26:32 +11:00
parent 3dcbf43749
commit f222ee0fe9
4 changed files with 561 additions and 24 deletions
BIN
View File
Binary file not shown.
+304 -2
View File
@@ -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; }
</style>
{% endblock %}
@@ -357,8 +481,21 @@
<!-- Main Content - Optimized Layout -->
<div class="main-content max-w-5xl mx-auto px-2 py-2">
<!-- Two-column layout: sticky TOC sidebar + main content -->
<div id="post-toc-layout">
<!-- TOC Sidebar (desktop only, sticky, populated by JS) -->
<aside id="toc-sidebar">
<div id="toc-sidebar-inner">
<div>
<p style="font-size:0.7rem;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.75rem;user-select:none;">Contents</p>
<nav id="toc-nav"></nav>
</div>
</div>
</aside>
<!-- Main Content -->
<div class="main-content" id="post-main-content">
<!-- Title and Actions - Responsive Layout -->
<div class="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3 mb-4">
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900 sm:flex-1">{{ post.title }}</h1>
@@ -555,6 +692,31 @@
{% trans "Append" %}
</button>
</div>
</div><!-- end post-main-content -->
</div><!-- end post-toc-layout -->
<!-- Mobile TOC floating button -->
<button id="toc-mobile-btn" title="Table of Contents" aria-label="Table of Contents">
<svg width="20" height="20" 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>
<!-- Mobile TOC backdrop -->
<div id="toc-mobile-backdrop" aria-hidden="true"></div>
<!-- Mobile TOC drawer -->
<div id="toc-mobile-drawer">
<div style="padding:1rem;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;padding-bottom:0.75rem;border-bottom:1px solid #f3f4f6;">
<p style="font-weight:600;color:#374151;margin:0;">{% trans "Contents" %}</p>
<button id="toc-mobile-close" style="background:none;border:none;cursor:pointer;padding:0.25rem;color:#9ca3af;border-radius:0.25rem;" aria-label="Close">
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<nav id="toc-nav-mobile"></nav>
</div>
</div>
{% 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);
});
})();
</script>
{% endblock %}
+255 -22
View File
@@ -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; }
</style>
</head>
<body class="bg-gray-100">
<div class="max-w-4xl mx-auto mt-10 p-8 bg-white shadow-md rounded-lg">
<!-- Post Header -->
<div class="mb-8">
<div class="flex items-center space-x-4">
<div class="flex-1">
<h1 class="text-3xl font-bold text-gray-900">{{ post.title }}</h1>
<div class="mt-2 flex items-center text-sm text-gray-500">
<span>{{ post.created_at|date:"Y-m-d H:i" }}</span>
</div>
<!-- Two-column layout: sticky TOC sidebar + main content -->
<div id="post-toc-layout">
<!-- TOC Sidebar (desktop only, sticky, populated by JS) -->
<aside id="toc-sidebar">
<div id="toc-sidebar-inner">
<div>
<p style="font-size:0.7rem;font-weight:600;color:#9ca3af;text-transform:uppercase;letter-spacing:0.05em;margin-bottom:0.75rem;user-select:none;">Contents</p>
<nav id="toc-nav"></nav>
</div>
</div>
{% if post.tags.exists %}
<div class="flex items-center space-x-2 mt-2">
{% for tag in post.tags.all %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ tag.name }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
</aside>
<!-- Post Content -->
<div class="prose">
{% markdown_with_tasks post.content post.task_states %}
<!-- Main Content -->
<div id="post-main-content" style="padding:2rem;background:#fff;box-shadow:0 1px 3px 0 rgba(0,0,0,.1);border-radius:0.5rem;">
<!-- Post Header -->
<div class="mb-8">
<div class="flex items-center space-x-4">
<div class="flex-1">
<h1 class="text-3xl font-bold text-gray-900">{{ post.title }}</h1>
<div class="mt-2 flex items-center text-sm text-gray-500">
<span>{{ post.created_at|date:"Y-m-d H:i" }}</span>
</div>
</div>
</div>
{% if post.tags.exists %}
<div class="flex items-center space-x-2 mt-2">
{% for tag in post.tags.all %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
{{ tag.name }}
</span>
{% endfor %}
</div>
{% endif %}
</div>
<!-- Post Content -->
<div class="prose">
{% markdown_with_tasks post.content post.task_states %}
</div>
</div>
</div>
<!-- Mobile TOC floating button -->
<button id="toc-mobile-btn" title="Table of Contents" aria-label="Table of Contents">
<svg width="20" height="20" 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>
<!-- Mobile TOC backdrop -->
<div id="toc-mobile-backdrop" aria-hidden="true"></div>
<!-- Mobile TOC drawer -->
<div id="toc-mobile-drawer">
<div style="padding:1rem;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1rem;padding-bottom:0.75rem;border-bottom:1px solid #f3f4f6;">
<p style="font-weight:600;color:#374151;margin:0;">Contents</p>
<button id="toc-mobile-close" style="background:none;border:none;cursor:pointer;padding:0.25rem;color:#9ca3af;border-radius:0.25rem;" aria-label="Close">
<svg width="20" height="20" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<nav id="toc-nav-mobile"></nav>
</div>
</div>
@@ -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);
});
})();
</script>
</body>
</html>
+2
View File
@@ -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)
]