2
0

feat(pages): add blog reading enhancements and gallery lightbox
Some checks failed
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 5m23s
Build and Release / Create Release (push) Successful in 0s
Build and Release / Lint (push) Successful in 6m8s
Build and Release / Unit Tests (push) Successful in 6m12s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Successful in 3m37s
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Successful in 9h5m0s
Build and Release / Build Binaries (amd64, darwin, macos) (push) Failing after 1s
Build and Release / Build Binaries (arm64, darwin, macos) (push) Failing after 0s
Build and Release / Build Binary (linux/arm64) (push) Failing after 13m52s

Add reading progress bar, table of contents sidebar, and back-to-top button for blog post detail pages. TOC auto-generates from h1-h4 headings with smooth scroll navigation and active section highlighting. Sidebar expands on hover from left edge. Add lightbox for gallery images with keyboard navigation (arrows, escape) and captions. Back-to-top button appears after 400px scroll on all pages. All features use inline styles for portability and minimal CSS conflicts.
This commit is contained in:
2026-03-16 21:43:46 -04:00
parent fa016ab865
commit 818c2db411
8 changed files with 211 additions and 10 deletions

View File

@@ -10,5 +10,19 @@
{{template "custom/body_outer_post" .}}
{{template "base/footer_content" .}}
{{template "custom/footer" .}}
<button id="back-to-top" style="position:fixed; bottom:32px; right:32px; z-index:9997; width:44px; height:44px; border-radius:50%; border:none; background:var(--color-secondary-bg, rgba(0,0,0,0.65)); color:var(--color-secondary-text, #fff); font-size:20px; cursor:pointer; display:flex; align-items:center; justify-content:center; opacity:0; pointer-events:none; transition:opacity 0.3s, transform 0.3s; transform:translateY(12px); box-shadow:0 2px 12px rgba(0,0,0,0.18);" aria-label="Back to top">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 13V3"/><path d="M3 7l5-5 5 5"/></svg>
</button>
<script>
(function(){
var b=document.getElementById('back-to-top');
if(!b)return;
window.addEventListener('scroll',function(){
if(window.scrollY>400){b.style.opacity='1';b.style.pointerEvents='auto';b.style.transform='translateY(0)';}
else{b.style.opacity='0';b.style.pointerEvents='none';b.style.transform='translateY(12px)';}
});
b.addEventListener('click',function(){window.scrollTo({top:0,behavior:'smooth'});});
})();
</script>
</body>
</html>

View File

@@ -1,3 +1,190 @@
{{if .GalleryImages}}
<div id="pages-lightbox" style="display:none; position:fixed; inset:0; z-index:9999; background:rgba(0,0,0,0.92); align-items:center; justify-content:center; flex-direction:column;">
<button id="pages-lb-close" style="position:absolute; top:16px; right:20px; background:none; border:none; color:#fff; font-size:32px; cursor:pointer; z-index:2; line-height:1;" aria-label="Close">&times;</button>
<button id="pages-lb-prev" style="position:absolute; left:16px; top:50%; transform:translateY(-50%); background:none; border:none; color:#fff; font-size:28px; cursor:pointer; z-index:2;" aria-label="Previous">&#8249;</button>
<button id="pages-lb-next" style="position:absolute; right:16px; top:50%; transform:translateY(-50%); background:none; border:none; color:#fff; font-size:28px; cursor:pointer; z-index:2;" aria-label="Next">&#8250;</button>
<img id="pages-lb-img" src="" alt="" style="max-width:90vw; max-height:82vh; object-fit:contain; border-radius:4px; user-select:none;">
<div id="pages-lb-caption" style="color:#ccc; font-size:14px; margin-top:12px; text-align:center; max-width:80vw;"></div>
</div>
<script>
(function(){
var lb=document.getElementById('pages-lightbox'),img=document.getElementById('pages-lb-img'),
cap=document.getElementById('pages-lb-caption'),items=[],idx=0;
document.querySelectorAll('.pages-gallery-trigger').forEach(function(el,i){
items.push({src:el.dataset.src,caption:el.dataset.caption||''});
el.addEventListener('click',function(e){e.preventDefault();idx=i;show();});
});
function show(){
if(!items.length)return;
img.src=items[idx].src;img.alt=items[idx].caption;
cap.textContent=items[idx].caption;
document.getElementById('pages-lb-prev').style.display=idx>0?'':'none';
document.getElementById('pages-lb-next').style.display=idx<items.length-1?'':'none';
lb.style.display='flex';document.body.style.overflow='hidden';
}
function hide(){lb.style.display='none';document.body.style.overflow='';}
document.getElementById('pages-lb-close').addEventListener('click',hide);
document.getElementById('pages-lb-prev').addEventListener('click',function(){if(idx>0){idx--;show();}});
document.getElementById('pages-lb-next').addEventListener('click',function(){if(idx<items.length-1){idx++;show();}});
lb.addEventListener('click',function(e){if(e.target===lb)hide();});
document.addEventListener('keydown',function(e){
if(lb.style.display!=='flex')return;
if(e.key==='Escape')hide();
else if(e.key==='ArrowLeft'&&idx>0){idx--;show();}
else if(e.key==='ArrowRight'&&idx<items.length-1){idx++;show();}
});
})();
</script>
{{end}}
{{if .PageIsBlogDetail}}
<!-- Reading Progress Bar -->
<div id="blog-progress" style="position:fixed; top:0; left:0; width:100%; height:3px; z-index:9998; background:transparent; pointer-events:none;">
<div id="blog-progress-bar" style="height:100%; width:0; background:var(--pages-primary,#4183c4); transition:width 80ms linear;"></div>
</div>
<!-- Table of Contents Sidebar -->
<div id="blog-toc" style="position:fixed; left:0; top:50%; transform:translateY(-50%); z-index:9997; display:none;">
<div id="blog-toc-tab" style="width:36px; height:36px; display:flex; align-items:center; justify-content:center; background:rgba(0,0,0,0.65); backdrop-filter:blur(8px); border-radius:0 8px 8px 0; cursor:pointer; color:#fff; font-size:14px; transition:opacity 0.2s;" title="Table of Contents">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="4" x2="13" y2="4"/><line x1="3" y1="8" x2="13" y2="8"/><line x1="3" y1="12" x2="10" y2="12"/></svg>
</div>
<div id="blog-toc-panel" style="position:absolute; left:0; top:50%; transform:translateY(-50%); background:rgba(0,0,0,0.78); backdrop-filter:blur(12px); border-radius:0 10px 10px 0; padding:16px 20px 16px 16px; min-width:220px; max-width:300px; max-height:70vh; overflow-y:auto; opacity:0; pointer-events:none; transition:opacity 0.2s, transform 0.2s; transform:translateY(-50%) translateX(-8px);">
<div style="font-size:11px; text-transform:uppercase; letter-spacing:0.08em; color:rgba(255,255,255,0.5); margin-bottom:10px; font-weight:600;">Contents</div>
<ul id="blog-toc-list" style="list-style:none; margin:0; padding:0;"></ul>
</div>
</div>
<!-- Back to Top Button -->
<button id="blog-back-top" style="position:fixed; bottom:32px; right:32px; z-index:9997; width:44px; height:44px; border-radius:50%; border:none; background:rgba(0,0,0,0.65); backdrop-filter:blur(8px); color:#fff; font-size:20px; cursor:pointer; display:flex; align-items:center; justify-content:center; opacity:0; pointer-events:none; transition:opacity 0.3s, transform 0.3s; transform:translateY(12px); box-shadow:0 2px 12px rgba(0,0,0,0.25);" aria-label="Back to top">
<svg width="18" height="18" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 13V3"/><path d="M3 7l5-5 5 5"/></svg>
</button>
<script>
(function(){
/* --- Heading scan & TOC build --- */
var content=document.querySelector('.markup');
if(!content)return;
var headings=content.querySelectorAll('h1,h2,h3,h4');
var tocList=document.getElementById('blog-toc-list');
var tocWrap=document.getElementById('blog-toc');
var tocTab=document.getElementById('blog-toc-tab');
var tocPanel=document.getElementById('blog-toc-panel');
var slugs={};
if(headings.length>=2&&tocList){
tocWrap.style.display='block';
headings.forEach(function(h){
/* generate slug ID */
var text=h.textContent.trim();
var slug=text.toLowerCase().replace(/[^\w\s-]/g,'').replace(/\s+/g,'-').replace(/-+/g,'-');
if(!slug)slug='section';
if(slugs[slug]){slugs[slug]++;slug=slug+'-'+slugs[slug];}
else{slugs[slug]=1;}
h.id=slug;
var level=parseInt(h.tagName.charAt(1));
var indent=(level-1)*14;
var li=document.createElement('li');
li.style.marginBottom='6px';
var a=document.createElement('a');
a.href='#'+slug;
a.textContent=text;
a.style.cssText='color:rgba(255,255,255,0.75);text-decoration:none;font-size:13px;display:block;padding:3px 0 3px '+indent+'px;border-radius:4px;transition:color 0.15s,background 0.15s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:260px;';
a.addEventListener('mouseenter',function(){a.style.color='#fff';});
a.addEventListener('mouseleave',function(){if(!a.classList.contains('active'))a.style.color='rgba(255,255,255,0.75)';});
a.addEventListener('click',function(e){
e.preventDefault();
h.scrollIntoView({behavior:'smooth',block:'start'});
});
a.dataset.target=slug;
li.appendChild(a);
tocList.appendChild(li);
});
}
/* --- TOC hover expand --- */
var showTimer,hideTimer;
function showPanel(){
clearTimeout(hideTimer);
tocPanel.style.opacity='1';
tocPanel.style.pointerEvents='auto';
tocPanel.style.transform='translateY(-50%) translateX(0)';
tocTab.style.opacity='0';
}
function hidePanel(){
hideTimer=setTimeout(function(){
tocPanel.style.opacity='0';
tocPanel.style.pointerEvents='none';
tocPanel.style.transform='translateY(-50%) translateX(-8px)';
tocTab.style.opacity='1';
},200);
}
if(tocTab&&tocPanel){
tocTab.addEventListener('mouseenter',showPanel);
tocTab.addEventListener('mouseleave',hidePanel);
tocPanel.addEventListener('mouseenter',function(){clearTimeout(hideTimer);});
tocPanel.addEventListener('mouseleave',hidePanel);
}
/* --- Active heading tracking --- */
var tocLinks=tocList?tocList.querySelectorAll('a'):[];
function updateActive(){
var scrollTop=window.scrollY||document.documentElement.scrollTop;
var current=null;
headings.forEach(function(h){
if(h.getBoundingClientRect().top<=100)current=h.id;
});
tocLinks.forEach(function(a){
if(a.dataset.target===current){
a.style.color='#fff';a.style.background='rgba(255,255,255,0.1)';
a.classList.add('active');
}else{
a.style.color='rgba(255,255,255,0.75)';a.style.background='transparent';
a.classList.remove('active');
}
});
}
/* --- Progress bar --- */
var bar=document.getElementById('blog-progress-bar');
function updateProgress(){
var docH=document.documentElement.scrollHeight-window.innerHeight;
if(docH<=0){bar.style.width='100%';return;}
var pct=Math.min(100,Math.max(0,(window.scrollY/docH)*100));
bar.style.width=pct+'%';
}
/* --- Back to top --- */
var btt=document.getElementById('blog-back-top');
function updateBtt(){
if(window.scrollY>400){
btt.style.opacity='1';btt.style.pointerEvents='auto';btt.style.transform='translateY(0)';
}else{
btt.style.opacity='0';btt.style.pointerEvents='none';btt.style.transform='translateY(12px)';
}
}
if(btt){
btt.addEventListener('click',function(){window.scrollTo({top:0,behavior:'smooth'});});
}
/* --- Unified scroll handler --- */
var ticking=false;
window.addEventListener('scroll',function(){
if(!ticking){
requestAnimationFrame(function(){
updateProgress();
updateBtt();
if(headings.length>=2)updateActive();
ticking=false;
});
ticking=true;
}
});
updateProgress();
updateBtt();
if(headings.length>=2)updateActive();
})();
</script>
{{end}}
{{if .ABTestActive}}
<script>
(function() {

View File

@@ -7,7 +7,7 @@
<title>{{.BlogPost.Title}} - {{if .Config.Brand.Name}}{{.Config.Brand.Name}}{{else}}{{.Repository.Name}}{{end}}</title>
<meta name="description" content="{{if .BlogPost.Subtitle}}{{.BlogPost.Subtitle}}{{else}}{{.Repository.Description}}{{end}}">
{{else}}
<title>{{if .Config.Hero.Headline}}{{.Config.Hero.Headline}}{{else}}{{.Repository.Name}}{{end}} - {{.Repository.Owner.Name}}</title>
<title>{{if .Config.Hero.Headline}}{{.Config.Hero.Headline}}{{else}}{{.Repository.Name}}{{end}} - {{.Repository.Owner.DisplayName}}</title>
<meta name="description" content="{{if .Config.Hero.Subheadline}}{{.Config.Hero.Subheadline}}{{else}}{{.Repository.Description}}{{end}}">
{{end}}
{{if and .PageIsBlogDetail .BlogPost}}

View File

@@ -1200,7 +1200,7 @@
<span>{{DateUtils.AbsoluteShort .BlogPost.CreatedUnix}}</span>
{{if .BlogTags}}<span>&middot;</span>{{range .BlogTags}}<span style="background: var(--nb-surface); border: 1px solid var(--nb-border-hard); padding: 2px 8px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.05em;">{{.}}</span> {{end}}{{end}}
</div>
<div class="markup nb-blog-content" style="color: var(--nb-text); line-height: 1.8; font-size: 16px; text-align: left;">
<div class="markup nb-blog-content" style="color: var(--nb-text); line-height: 1.8; font-size: 18px; text-align: left;">
{{.BlogRenderedContent}}
</div>
<div style="margin-top: 48px; padding-top: 24px; border-top: 2px solid var(--nb-border-hard);">
@@ -1571,7 +1571,7 @@
<div style="display: grid; grid-template-columns: repeat({{if .Config.Gallery.Columns}}{{.Config.Gallery.Columns}}{{else}}3{{end}}, 1fr); gap: 4px;">
{{range .GalleryImages}}
<div class="nb-reveal" style="overflow: hidden; clip-path: polygon(0 0, calc(100% - 12px) 0, 100% 12px, 100% 100%, 12px 100%, 0 calc(100% - 12px));">
<a href="{{.URL}}" target="_blank" style="display: block; position: relative;">
<a href="{{.URL}}" class="pages-gallery-trigger" data-src="{{.URL}}" data-caption="{{if .Caption}}{{.Caption}}{{else}}{{.Name}}{{end}}" style="display: block; position: relative; cursor: pointer;">
<img src="{{.URL}}" alt="{{if .Caption}}{{.Caption}}{{else}}{{.Name}}{{end}}" style="width: 100%; height: 220px; object-fit: cover; display: block;">
{{if .Caption}}
<div style="position: absolute; bottom: 0; left: 0; right: 0; padding: 12px 16px; background: linear-gradient(transparent, rgba(0,0,0,0.7)); font-size: 13px; color: #fff;">{{.Caption}}</div>

View File

@@ -1062,7 +1062,7 @@
<span>{{DateUtils.AbsoluteShort .BlogPost.CreatedUnix}}</span>
{{if .BlogTags}}<span>&middot;</span>{{range .BlogTags}}<span style="background: var(--ea-surface); padding: 2px 8px; border-radius: 3px; font-size: 12px; color: var(--ea-accent);">{{.}}</span> {{end}}{{end}}
</div>
<div class="markup ea-blog-content" style="color: var(--ea-text); line-height: 1.8; font-size: 16px; font-family: 'Cormorant Garamond', serif;">
<div class="markup ea-blog-content" style="color: var(--ea-text); line-height: 1.8; font-size: 18px; font-family: 'Cormorant Garamond', serif;">
{{.BlogRenderedContent}}
</div>
<div style="margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--ea-border);">
@@ -1443,7 +1443,7 @@
<div style="display: grid; grid-template-columns: repeat({{if .Config.Gallery.Columns}}{{.Config.Gallery.Columns}}{{else}}3{{end}}, 1fr); gap: 16px;">
{{range .GalleryImages}}
<div class="ea-reveal" style="overflow: hidden; border-radius: 4px; border: 1px solid var(--ea-border);">
<a href="{{.URL}}" target="_blank" style="display: block;">
<a href="{{.URL}}" class="pages-gallery-trigger" data-src="{{.URL}}" data-caption="{{if .Caption}}{{.Caption}}{{else}}{{.Name}}{{end}}" style="display: block; cursor: pointer;">
<img src="{{.URL}}" alt="{{if .Caption}}{{.Caption}}{{else}}{{.Name}}{{end}}" style="width: 100%; height: 200px; object-fit: cover; display: block;">
</a>
{{if .Caption}}

View File

@@ -1035,7 +1035,7 @@
<span>{{DateUtils.AbsoluteShort .BlogPost.CreatedUnix}}</span>
{{if .BlogTags}}<span>&middot;</span>{{range .BlogTags}}<span style="background: var(--osh-glow); padding: 2px 8px; border-radius: 4px; font-size: 12px;">{{.}}</span> {{end}}{{end}}
</div>
<div class="markup osh-blog-content" style="color: var(--osh-text); line-height: 1.8; font-size: 16px; text-align: left;">
<div class="markup osh-blog-content" style="color: var(--osh-text); line-height: 1.8; font-size: 18px; text-align: left;">
{{.BlogRenderedContent}}
</div>
<div style="margin-top: 48px; padding-top: 24px; border-top: 1px solid rgba(255,255,255,0.06);">
@@ -1442,7 +1442,7 @@
<div style="display: grid; grid-template-columns: repeat({{if .Config.Gallery.Columns}}{{.Config.Gallery.Columns}}{{else}}3{{end}}, 1fr); gap: 16px;">
{{range .GalleryImages}}
<div class="osh-feature-card osh-reveal" style="padding: 0; overflow: hidden;">
<a href="{{.URL}}" target="_blank" style="display: block;">
<a href="{{.URL}}" class="pages-gallery-trigger" data-src="{{.URL}}" data-caption="{{if .Caption}}{{.Caption}}{{else}}{{.Name}}{{end}}" style="display: block; cursor: pointer;">
<img src="{{.URL}}" alt="{{if .Caption}}{{.Caption}}{{else}}{{.Name}}{{end}}" style="width: 100%; height: 220px; object-fit: cover; display: block;">
</a>
{{if .Caption}}

View File

@@ -1173,7 +1173,7 @@
<span>{{DateUtils.AbsoluteShort .BlogPost.CreatedUnix}}</span>
{{if .BlogTags}}<span>&middot;</span>{{range .BlogTags}}<span style="background: var(--gm-glass); border: 1px solid var(--gm-glass-border); padding: 2px 8px; border-radius: 20px; font-size: 12px;">{{.}}</span> {{end}}{{end}}
</div>
<div class="markup gm-blog-content" style="color: var(--gm-text); line-height: 1.8; font-size: 16px; text-align: left;">
<div class="markup gm-blog-content" style="color: var(--gm-text); line-height: 1.8; font-size: 18px; text-align: left;">
{{.BlogRenderedContent}}
</div>
<div style="margin-top: 48px; padding-top: 24px; border-top: 1px solid var(--gm-glass-border);">
@@ -1581,7 +1581,7 @@
<div style="display: grid; grid-template-columns: repeat({{if .Config.Gallery.Columns}}{{.Config.Gallery.Columns}}{{else}}3{{end}}, 1fr); gap: 16px;">
{{range .GalleryImages}}
<div class="gm-value-card gm-reveal" style="padding: 0; overflow: hidden;">
<a href="{{.URL}}" target="_blank" style="display: block;">
<a href="{{.URL}}" class="pages-gallery-trigger" data-src="{{.URL}}" data-caption="{{if .Caption}}{{.Caption}}{{else}}{{.Name}}{{end}}" style="display: block; cursor: pointer;">
<img src="{{.URL}}" alt="{{if .Caption}}{{.Caption}}{{else}}{{.Name}}{{end}}" style="width: 100%; height: 220px; object-fit: cover; display: block;">
</a>
{{if .Caption}}

View File

@@ -8,7 +8,7 @@
</div>
<div class="flex-item-main">
<div class="flex-item-title tw-text-18">
<a class="muted tw-font-normal" href="{{.Owner.HomeLink}}">{{.Owner.Name}}</a>/<a class="muted" href="{{$.RepoLink}}">{{if .DisplayTitle}}{{.DisplayTitle}}{{else}}{{.Name}}{{end}}</a>
<a class="muted tw-font-normal" href="{{.Owner.HomeLink}}">{{.Owner.DisplayName}}</a>/<a class="muted" href="{{$.RepoLink}}">{{if .DisplayTitle}}{{.DisplayTitle}}{{else}}{{.Name}}{{end}}</a>
</div>
</div>
<div class="flex-item-trailing">