feat: WO-PS-011 SDK templates/bn shared components
Head, engagement, toolbar templ components and validation helpers for use by template plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
71d3416304
commit
868df2d761
@ -1 +0,0 @@
|
|||||||
package bn
|
|
||||||
514
templates/bn/engagement.templ
Normal file
514
templates/bn/engagement.templ
Normal file
@ -0,0 +1,514 @@
|
|||||||
|
package bn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EngagementConfig contains configuration for the engagement tracker
|
||||||
|
type EngagementConfig struct {
|
||||||
|
PagePath string `json:"pagePath"`
|
||||||
|
PageID string `json:"pageId"`
|
||||||
|
PostWordCount int `json:"postWordCount"`
|
||||||
|
SessionID string `json:"sessionId"`
|
||||||
|
VisitorHash string `json:"visitorHash"`
|
||||||
|
IsPost bool `json:"isPost"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// engagementConfigJSON converts the config to JSON for embedding in the script
|
||||||
|
func engagementConfigJSON(config EngagementConfig) string {
|
||||||
|
data, err := json.Marshal(config)
|
||||||
|
if err != nil {
|
||||||
|
return "{}"
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngagementScript renders the blog post engagement tracking script.
|
||||||
|
// Only renders if IsPost is true to avoid bloating regular pages.
|
||||||
|
templ EngagementScript(config EngagementConfig) {
|
||||||
|
if config.IsPost && config.PageID != "" {
|
||||||
|
<script data-engagement-config={ engagementConfigJSON(config) }>
|
||||||
|
(function(){
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Parse config from script tag
|
||||||
|
var script = document.currentScript;
|
||||||
|
var cfg;
|
||||||
|
try { cfg = JSON.parse(script.getAttribute('data-engagement-config')); } catch(e) { return; }
|
||||||
|
if (!cfg.isPost) return;
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// State
|
||||||
|
// =================================================================
|
||||||
|
var state = {
|
||||||
|
sessionId: cfg.sessionId || crypto.randomUUID(),
|
||||||
|
visitorHash: cfg.visitorHash || '',
|
||||||
|
pageStart: Date.now(),
|
||||||
|
activeStart: Date.now(),
|
||||||
|
totalActiveTime: 0,
|
||||||
|
isActive: true,
|
||||||
|
lastActivityTime: Date.now(),
|
||||||
|
maxScrollPercent: 0,
|
||||||
|
scrollMilestones: {},
|
||||||
|
viewedHeadings: {},
|
||||||
|
currentHeading: null,
|
||||||
|
mouseMovements: 0,
|
||||||
|
scrollEvents: [],
|
||||||
|
lastScrollTime: 0,
|
||||||
|
lastScrollPos: 0,
|
||||||
|
directionChanges: 0,
|
||||||
|
eventQueue: [],
|
||||||
|
flushTimer: null,
|
||||||
|
clickPositions: [],
|
||||||
|
interactiveTags: {'A':1,'BUTTON':1,'INPUT':1,'SELECT':1,'TEXTAREA':1,'LABEL':1},
|
||||||
|
sentExit: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Utility Functions
|
||||||
|
// =================================================================
|
||||||
|
function getScrollPercent() {
|
||||||
|
var docHeight = document.documentElement.scrollHeight - window.innerHeight;
|
||||||
|
if (docHeight <= 0) return 100;
|
||||||
|
return Math.min(100, Math.round((window.scrollY / docHeight) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getContentDepthPercent() {
|
||||||
|
var content = document.querySelector('[data-slot="main"]') ||
|
||||||
|
document.querySelector('article') ||
|
||||||
|
document.querySelector('main');
|
||||||
|
if (!content) return getScrollPercent();
|
||||||
|
|
||||||
|
var rect = content.getBoundingClientRect();
|
||||||
|
var contentTop = window.scrollY + rect.top;
|
||||||
|
var contentHeight = rect.height;
|
||||||
|
var viewportBottom = window.scrollY + window.innerHeight;
|
||||||
|
|
||||||
|
var contentViewed = Math.max(0, viewportBottom - contentTop);
|
||||||
|
return Math.min(100, Math.round((contentViewed / contentHeight) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleHeadings() {
|
||||||
|
var headings = document.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
||||||
|
var visible = [];
|
||||||
|
var viewportTop = window.scrollY;
|
||||||
|
var viewportBottom = viewportTop + window.innerHeight;
|
||||||
|
|
||||||
|
for (var i = 0; i < headings.length; i++) {
|
||||||
|
var h = headings[i];
|
||||||
|
var rect = h.getBoundingClientRect();
|
||||||
|
var absTop = window.scrollY + rect.top;
|
||||||
|
var absBottom = absTop + rect.height;
|
||||||
|
|
||||||
|
if (absBottom >= viewportTop && absTop <= viewportBottom) {
|
||||||
|
visible.push({
|
||||||
|
id: h.id || 'heading-' + i,
|
||||||
|
text: (h.textContent || '').trim().substring(0, 100),
|
||||||
|
level: parseInt(h.tagName.substring(1)),
|
||||||
|
top: absTop
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashPhrase(str) {
|
||||||
|
var hash = 0;
|
||||||
|
for (var i = 0; i < str.length; i++) {
|
||||||
|
var chr = str.charCodeAt(i);
|
||||||
|
hash = ((hash << 5) - hash) + chr;
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return hash.toString(16);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Quality Score Calculation
|
||||||
|
// =================================================================
|
||||||
|
function calculateQualityScore() {
|
||||||
|
var score = 50;
|
||||||
|
|
||||||
|
// Positive signals
|
||||||
|
if (state.mouseMovements > 10) score += 10;
|
||||||
|
if (state.totalActiveTime > 30000) score += 15;
|
||||||
|
if (state.scrollEvents.length > 5) score += 10;
|
||||||
|
|
||||||
|
// Scroll naturalness
|
||||||
|
if (state.scrollEvents.length >= 3) {
|
||||||
|
var intervals = [];
|
||||||
|
for (var i = 1; i < state.scrollEvents.length; i++) {
|
||||||
|
intervals.push(state.scrollEvents[i].time - state.scrollEvents[i-1].time);
|
||||||
|
}
|
||||||
|
var avg = intervals.reduce(function(a,b){ return a+b; }, 0) / intervals.length;
|
||||||
|
var variance = intervals.reduce(function(sum, i){ return sum + Math.pow(i - avg, 2); }, 0) / intervals.length;
|
||||||
|
if (variance > 50000) score += 10;
|
||||||
|
if (variance < 1000 && intervals.length > 10) score -= 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Direction changes indicate engaged reading
|
||||||
|
if (state.directionChanges > 2) score += 5;
|
||||||
|
|
||||||
|
// Time vs content
|
||||||
|
var expectedReadTime = (cfg.postWordCount / 200) * 60 * 1000;
|
||||||
|
var actualTime = Date.now() - state.pageStart;
|
||||||
|
var readRatio = actualTime / expectedReadTime;
|
||||||
|
if (readRatio > 0.3 && readRatio < 5) score += 10;
|
||||||
|
|
||||||
|
// Negative signals
|
||||||
|
if (document.hidden && state.totalActiveTime < 5000) score -= 20;
|
||||||
|
|
||||||
|
// Instant 100% scroll is suspicious
|
||||||
|
if (state.maxScrollPercent >= 100 && actualTime < 3000) score -= 30;
|
||||||
|
|
||||||
|
return Math.max(0, Math.min(100, score));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Event Queue & Batching
|
||||||
|
// =================================================================
|
||||||
|
function queueEvent(type, name, data) {
|
||||||
|
state.eventQueue.push({
|
||||||
|
t: type,
|
||||||
|
n: name,
|
||||||
|
d: data,
|
||||||
|
ts: Date.now()
|
||||||
|
});
|
||||||
|
|
||||||
|
if (state.eventQueue.length >= 10) {
|
||||||
|
flushEvents();
|
||||||
|
} else if (!state.flushTimer) {
|
||||||
|
state.flushTimer = setTimeout(flushEvents, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushEvents() {
|
||||||
|
if (state.flushTimer) {
|
||||||
|
clearTimeout(state.flushTimer);
|
||||||
|
state.flushTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.eventQueue.length === 0) return;
|
||||||
|
|
||||||
|
var payload = {
|
||||||
|
pp: cfg.pagePath,
|
||||||
|
pid: cfg.pageId,
|
||||||
|
sid: state.sessionId,
|
||||||
|
vh: state.visitorHash,
|
||||||
|
qs: calculateQualityScore(),
|
||||||
|
events: state.eventQueue.slice()
|
||||||
|
};
|
||||||
|
|
||||||
|
state.eventQueue = [];
|
||||||
|
|
||||||
|
navigator.sendBeacon('/api/engagement', JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Scroll Tracking
|
||||||
|
// =================================================================
|
||||||
|
var scrollThrottle = null;
|
||||||
|
function handleScroll() {
|
||||||
|
if (scrollThrottle) return;
|
||||||
|
scrollThrottle = setTimeout(function() {
|
||||||
|
scrollThrottle = null;
|
||||||
|
|
||||||
|
var percent = getScrollPercent();
|
||||||
|
var contentPercent = getContentDepthPercent();
|
||||||
|
var now = Date.now();
|
||||||
|
|
||||||
|
// Track scroll direction changes
|
||||||
|
var currentPos = window.scrollY;
|
||||||
|
if (state.lastScrollPos !== 0) {
|
||||||
|
var wasGoingDown = state.scrollEvents.length > 0 &&
|
||||||
|
state.scrollEvents[state.scrollEvents.length - 1].pos < currentPos;
|
||||||
|
var isGoingDown = currentPos > state.lastScrollPos;
|
||||||
|
if (state.scrollEvents.length > 1 && wasGoingDown !== isGoingDown) {
|
||||||
|
state.directionChanges++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.lastScrollPos = currentPos;
|
||||||
|
|
||||||
|
// Track scroll event for quality scoring
|
||||||
|
state.scrollEvents.push({ time: now, percent: percent, pos: currentPos });
|
||||||
|
if (state.scrollEvents.length > 50) state.scrollEvents.shift();
|
||||||
|
|
||||||
|
// Detect jump scrolling (>500px instant)
|
||||||
|
if (state.scrollEvents.length >= 2) {
|
||||||
|
var prev = state.scrollEvents[state.scrollEvents.length - 2];
|
||||||
|
var posDiff = Math.abs(currentPos - prev.pos);
|
||||||
|
var timeDiff = now - prev.time;
|
||||||
|
if (posDiff > 500 && timeDiff < 50) {
|
||||||
|
queueEvent('navigation', 'jump_scroll', { distance: posDiff });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detect erratic scrolling (rapid direction changes)
|
||||||
|
if (state.directionChanges > 5 && now - state.pageStart < 10000) {
|
||||||
|
queueEvent('frustration', 'erratic_scroll', {});
|
||||||
|
state.directionChanges = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update max scroll
|
||||||
|
if (percent > state.maxScrollPercent) {
|
||||||
|
state.maxScrollPercent = percent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check milestones
|
||||||
|
[25, 50, 75, 100].forEach(function(milestone) {
|
||||||
|
if (percent >= milestone && !state.scrollMilestones[milestone]) {
|
||||||
|
state.scrollMilestones[milestone] = true;
|
||||||
|
queueEvent('scroll', 'scroll_' + milestone, {
|
||||||
|
maxScrollPercent: state.maxScrollPercent,
|
||||||
|
contentDepthPercent: contentPercent,
|
||||||
|
timeToMilestoneMs: now - state.pageStart
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track heading visibility
|
||||||
|
var visible = getVisibleHeadings();
|
||||||
|
var newHeading = visible[0];
|
||||||
|
|
||||||
|
if (state.currentHeading && (!newHeading || newHeading.id !== state.currentHeading.id)) {
|
||||||
|
var entry = state.viewedHeadings[state.currentHeading.id];
|
||||||
|
if (entry) {
|
||||||
|
entry.totalDwell += now - entry.lastEnter;
|
||||||
|
queueEvent('section', 'heading_viewed', {
|
||||||
|
headingId: state.currentHeading.id,
|
||||||
|
headingText: state.currentHeading.text,
|
||||||
|
headingLevel: state.currentHeading.level,
|
||||||
|
dwellTimeMs: entry.totalDwell
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newHeading && (!state.currentHeading || newHeading.id !== state.currentHeading.id)) {
|
||||||
|
if (!state.viewedHeadings[newHeading.id]) {
|
||||||
|
state.viewedHeadings[newHeading.id] = { lastEnter: now, totalDwell: 0 };
|
||||||
|
} else {
|
||||||
|
var e = state.viewedHeadings[newHeading.id];
|
||||||
|
e.lastEnter = now;
|
||||||
|
queueEvent('section', 'reread', {
|
||||||
|
headingId: newHeading.id,
|
||||||
|
headingText: newHeading.text
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.currentHeading = newHeading;
|
||||||
|
state.lastScrollTime = now;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Visibility / Active Time Tracking
|
||||||
|
// =================================================================
|
||||||
|
function handleVisibilityChange() {
|
||||||
|
var now = Date.now();
|
||||||
|
if (document.hidden) {
|
||||||
|
if (state.isActive) {
|
||||||
|
state.totalActiveTime += now - state.activeStart;
|
||||||
|
state.isActive = false;
|
||||||
|
}
|
||||||
|
// Early blur might indicate bounce intent
|
||||||
|
if (now - state.pageStart < 10000 && state.maxScrollPercent < 25) {
|
||||||
|
queueEvent('frustration', 'early_blur', { timeOnPage: now - state.pageStart });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.activeStart = now;
|
||||||
|
state.isActive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Idle detection
|
||||||
|
function handleActivity() {
|
||||||
|
state.lastActivityTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Interaction Tracking
|
||||||
|
// =================================================================
|
||||||
|
var selectionTimeout = null;
|
||||||
|
function handleTextSelection() {
|
||||||
|
if (selectionTimeout) clearTimeout(selectionTimeout);
|
||||||
|
selectionTimeout = setTimeout(function() {
|
||||||
|
var selection = window.getSelection();
|
||||||
|
if (selection && selection.toString().trim().length > 5) {
|
||||||
|
var text = selection.toString().trim();
|
||||||
|
if (text.length > 50) text = text.substring(0, 50);
|
||||||
|
|
||||||
|
var range = selection.getRangeAt(0);
|
||||||
|
var container = range.commonAncestorContainer;
|
||||||
|
var nearestHeading = container.parentElement?.closest('h1,h2,h3,h4,h5,h6');
|
||||||
|
|
||||||
|
queueEvent('interaction', 'text_select', {
|
||||||
|
phrase: text,
|
||||||
|
phraseHash: hashPhrase(text),
|
||||||
|
length: selection.toString().length,
|
||||||
|
nearHeading: nearestHeading?.id || null,
|
||||||
|
nearHeadingText: nearestHeading?.textContent?.substring(0, 50) || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCopy(e) {
|
||||||
|
var selection = window.getSelection()?.toString() || '';
|
||||||
|
queueEvent('interaction', 'copy', {
|
||||||
|
length: selection.length
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLinkClick(e) {
|
||||||
|
var link = e.target.closest('a');
|
||||||
|
if (!link || !link.href) return;
|
||||||
|
|
||||||
|
var isExternal = !link.href.startsWith(window.location.origin);
|
||||||
|
var isTOC = link.closest('[class*="toc"]') || link.closest('nav[aria-label*="table"]');
|
||||||
|
var isAnchor = link.href.includes('#') && link.href.split('#')[0] === window.location.href.split('#')[0];
|
||||||
|
|
||||||
|
if (isTOC) {
|
||||||
|
queueEvent('navigation', 'toc_click', {});
|
||||||
|
} else if (isAnchor) {
|
||||||
|
queueEvent('navigation', 'anchor_jump', {});
|
||||||
|
} else {
|
||||||
|
queueEvent('interaction', 'link_click', {
|
||||||
|
isExternal: isExternal,
|
||||||
|
domain: isExternal ? new URL(link.href).hostname : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(e) {
|
||||||
|
var now = Date.now();
|
||||||
|
var x = e.clientX, y = e.clientY;
|
||||||
|
|
||||||
|
// Track click position for rage click detection
|
||||||
|
state.clickPositions.push({ x: x, y: y, time: now });
|
||||||
|
if (state.clickPositions.length > 10) state.clickPositions.shift();
|
||||||
|
|
||||||
|
// Rage click detection: 3+ clicks within 500ms in 50px radius
|
||||||
|
var recentClicks = state.clickPositions.filter(function(c) {
|
||||||
|
return now - c.time < 500 &&
|
||||||
|
Math.abs(c.x - x) < 50 &&
|
||||||
|
Math.abs(c.y - y) < 50;
|
||||||
|
});
|
||||||
|
if (recentClicks.length >= 3) {
|
||||||
|
queueEvent('frustration', 'rage_click', { count: recentClicks.length });
|
||||||
|
state.clickPositions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dead click detection: click on non-interactive element
|
||||||
|
var tag = e.target.tagName;
|
||||||
|
var isInteractive = state.interactiveTags[tag] ||
|
||||||
|
e.target.onclick ||
|
||||||
|
e.target.closest('a') ||
|
||||||
|
e.target.closest('button') ||
|
||||||
|
e.target.getAttribute('role') === 'button';
|
||||||
|
|
||||||
|
if (!isInteractive && e.target.closest('[data-slot="main"]')) {
|
||||||
|
queueEvent('frustration', 'dead_click', {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image viewing
|
||||||
|
function initImageTracking() {
|
||||||
|
var images = document.querySelectorAll('article img, [data-slot="main"] img');
|
||||||
|
var observer = new IntersectionObserver(function(entries) {
|
||||||
|
entries.forEach(function(entry) {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
queueEvent('interaction', 'image_view', {
|
||||||
|
alt: (entry.target.alt || '').substring(0, 50)
|
||||||
|
});
|
||||||
|
observer.unobserve(entry.target);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, { threshold: 0.5 });
|
||||||
|
|
||||||
|
images.forEach(function(img) { observer.observe(img); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Code block interaction
|
||||||
|
function initCodeBlockTracking() {
|
||||||
|
var blocks = document.querySelectorAll('pre, code');
|
||||||
|
blocks.forEach(function(block) {
|
||||||
|
block.addEventListener('click', function() {
|
||||||
|
queueEvent('interaction', 'code_interact', {});
|
||||||
|
}, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
function handleKeyDown(e) {
|
||||||
|
// Ctrl+F / Cmd+F detection
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
||||||
|
queueEvent('frustration', 'search_attempt', {});
|
||||||
|
}
|
||||||
|
// Ctrl+P / Cmd+P detection
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'p') {
|
||||||
|
queueEvent('navigation', 'print_attempt', {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMouseMove() {
|
||||||
|
state.mouseMovements++;
|
||||||
|
state.lastActivityTime = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Page Exit
|
||||||
|
// =================================================================
|
||||||
|
function handlePageExit() {
|
||||||
|
if (state.sentExit) return;
|
||||||
|
state.sentExit = true;
|
||||||
|
|
||||||
|
// Finalize active time
|
||||||
|
if (state.isActive) {
|
||||||
|
state.totalActiveTime += Date.now() - state.activeStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate reading velocity
|
||||||
|
var readTimeMinutes = state.totalActiveTime / 60000;
|
||||||
|
var wpm = readTimeMinutes > 0 ? cfg.postWordCount / readTimeMinutes : 0;
|
||||||
|
|
||||||
|
// Send exit event
|
||||||
|
queueEvent('scroll', 'page_exit', {
|
||||||
|
exitScrollPercent: state.maxScrollPercent,
|
||||||
|
totalTimeMs: Date.now() - state.pageStart,
|
||||||
|
activeTimeMs: state.totalActiveTime,
|
||||||
|
readingVelocityWpm: Math.round(wpm),
|
||||||
|
maxScrollPercent: state.maxScrollPercent,
|
||||||
|
directionChanges: state.directionChanges
|
||||||
|
});
|
||||||
|
|
||||||
|
// Flush immediately on exit
|
||||||
|
flushEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Initialize
|
||||||
|
// =================================================================
|
||||||
|
window.addEventListener('scroll', handleScroll, { passive: true });
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
document.addEventListener('selectionchange', handleTextSelection);
|
||||||
|
document.addEventListener('copy', handleCopy);
|
||||||
|
document.addEventListener('click', handleClick);
|
||||||
|
document.addEventListener('click', handleLinkClick);
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
document.addEventListener('mousemove', handleMouseMove, { passive: true });
|
||||||
|
document.addEventListener('touchstart', handleActivity, { passive: true });
|
||||||
|
window.addEventListener('beforeunload', handlePageExit);
|
||||||
|
window.addEventListener('pagehide', handlePageExit);
|
||||||
|
|
||||||
|
// Initialize observers
|
||||||
|
initImageTracking();
|
||||||
|
initCodeBlockTracking();
|
||||||
|
|
||||||
|
// Initial scroll check
|
||||||
|
setTimeout(handleScroll, 100);
|
||||||
|
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
80
templates/bn/engagement_templ.go
Normal file
80
templates/bn/engagement_templ.go
Normal file
File diff suppressed because one or more lines are too long
915
templates/bn/head.templ
Normal file
915
templates/bn/head.templ
Normal file
@ -0,0 +1,915 @@
|
|||||||
|
package bn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// resolveMediaURL converts media: prefixed URLs to proper /media/ paths
|
||||||
|
// e.g., "media:abc/image.webp" → "/media/abc/image.webp"
|
||||||
|
func resolveMediaURL(url string) string {
|
||||||
|
if strings.HasPrefix(url, "media:") {
|
||||||
|
return "/media/" + strings.TrimPrefix(url, "media:")
|
||||||
|
}
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
// BrandingData contains generated favicon/icon URLs
|
||||||
|
type BrandingData struct {
|
||||||
|
FaviconICO string // /brand/{id}/favicon.ico
|
||||||
|
Favicon16 string // /brand/{id}/favicon-16x16.png
|
||||||
|
Favicon32 string // /brand/{id}/favicon-32x32.png
|
||||||
|
AppleTouchIcon string // /brand/{id}/apple-touch-icon.png (180x180)
|
||||||
|
Android192 string // /brand/{id}/android-chrome-192x192.png
|
||||||
|
Android512 string // /brand/{id}/android-chrome-512x512.png
|
||||||
|
Maskable512 string // /brand/{id}/maskable-512x512.png
|
||||||
|
Master1024 string // /brand/{id}/icon-1024x1024.png
|
||||||
|
ManifestURL string // /brand/{id}/site.webmanifest
|
||||||
|
ThemeColor string
|
||||||
|
SVG string // Optional SVG pass-through
|
||||||
|
IsGenerated bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SiteSettingsData contains site-wide settings for head injection
|
||||||
|
type SiteSettingsData struct {
|
||||||
|
Title string
|
||||||
|
Description string
|
||||||
|
Favicon string
|
||||||
|
Logo string
|
||||||
|
LogoAlt string
|
||||||
|
AppleTouchIcon string
|
||||||
|
GoogleAnalyticsID string
|
||||||
|
CustomHeadScripts string
|
||||||
|
CustomBodyScripts string
|
||||||
|
MetaDescription string
|
||||||
|
DefaultOGImage string
|
||||||
|
TwitterHandle string
|
||||||
|
OGSiteName string
|
||||||
|
Branding BrandingData
|
||||||
|
AdminBypassMode string // "maintenance", "coming_soon", or "" if not bypassing
|
||||||
|
Toolbar ToolbarData
|
||||||
|
LLMsTxtEnabled bool // Whether llms.txt is enabled for AI content discovery
|
||||||
|
TurnstileSiteKey string // Cloudflare Turnstile site key for bot protection
|
||||||
|
RSSFeedURL string // URL to the RSS feed (e.g., "/rss"), empty to disable
|
||||||
|
RSSFeedTitle string // Feed title for discovery link
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageMeta contains page-level SEO meta data (overrides site defaults)
|
||||||
|
type PageMeta struct {
|
||||||
|
MetaTitle string // Custom SEO title (overrides page title)
|
||||||
|
MetaDescription string // Page-specific meta description
|
||||||
|
OGTitle string // Open Graph title
|
||||||
|
OGDescription string // Open Graph description
|
||||||
|
OGImage string // Open Graph image URL
|
||||||
|
TwitterTitle string // Twitter card title
|
||||||
|
TwitterDescription string // Twitter card description
|
||||||
|
TwitterImage string // Twitter card image URL
|
||||||
|
CanonicalURL string // Canonical URL for this page
|
||||||
|
RobotsDirective string // Robots directive (e.g., "noindex, nofollow")
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeadData contains all data needed to render the <head> element
|
||||||
|
type HeadData struct {
|
||||||
|
Title string
|
||||||
|
Settings SiteSettingsData
|
||||||
|
PageMeta PageMeta // Page-level SEO overrides
|
||||||
|
ThemeCSS string
|
||||||
|
ThemeMode string // "light", "dark", or "system"
|
||||||
|
PluginStyles []string // Additional stylesheet URLs
|
||||||
|
StructuredData string // JSON-LD structured data
|
||||||
|
CSSHash string // Cache-busting hash for styles.css
|
||||||
|
PageviewNonce string // Unique nonce for pageview deduplication
|
||||||
|
EngagementConfig EngagementConfig // Engagement tracking config (for blog posts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseEngagementConfig extracts engagement config from the document map
|
||||||
|
func ParseEngagementConfig(doc map[string]any) EngagementConfig {
|
||||||
|
config := EngagementConfig{}
|
||||||
|
|
||||||
|
engData, ok := doc["engagement_config"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := engData["page_path"].(string); ok {
|
||||||
|
config.PagePath = v
|
||||||
|
}
|
||||||
|
if v, ok := engData["page_id"].(string); ok {
|
||||||
|
config.PageID = v
|
||||||
|
}
|
||||||
|
if v, ok := engData["post_word_count"].(int); ok {
|
||||||
|
config.PostWordCount = v
|
||||||
|
}
|
||||||
|
if v, ok := engData["session_id"].(string); ok {
|
||||||
|
config.SessionID = v
|
||||||
|
}
|
||||||
|
if v, ok := engData["visitor_hash"].(string); ok {
|
||||||
|
config.VisitorHash = v
|
||||||
|
}
|
||||||
|
if v, ok := engData["is_post"].(bool); ok {
|
||||||
|
config.IsPost = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSiteSettings extracts site settings from the document map
|
||||||
|
func ParseSiteSettings(doc map[string]any) SiteSettingsData {
|
||||||
|
settings := SiteSettingsData{}
|
||||||
|
|
||||||
|
siteData, ok := doc["site_settings"].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
if v, ok := siteData["title"].(string); ok {
|
||||||
|
settings.Title = v
|
||||||
|
}
|
||||||
|
if v, ok := siteData["description"].(string); ok {
|
||||||
|
settings.Description = v
|
||||||
|
}
|
||||||
|
if v, ok := siteData["favicon"].(string); ok {
|
||||||
|
settings.Favicon = v
|
||||||
|
}
|
||||||
|
if v, ok := siteData["logo"].(string); ok {
|
||||||
|
settings.Logo = v
|
||||||
|
}
|
||||||
|
if v, ok := siteData["logo_alt"].(string); ok {
|
||||||
|
settings.LogoAlt = v
|
||||||
|
}
|
||||||
|
if v, ok := siteData["apple_touch_icon"].(string); ok {
|
||||||
|
settings.AppleTouchIcon = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// SEO defaults
|
||||||
|
if seoData, ok := siteData["seo_defaults"].(map[string]any); ok {
|
||||||
|
if v, ok := seoData["meta_description"].(string); ok {
|
||||||
|
settings.MetaDescription = v
|
||||||
|
}
|
||||||
|
if v, ok := seoData["default_og_image"].(string); ok {
|
||||||
|
settings.DefaultOGImage = v
|
||||||
|
}
|
||||||
|
if v, ok := seoData["twitter_handle"].(string); ok {
|
||||||
|
settings.TwitterHandle = v
|
||||||
|
}
|
||||||
|
if v, ok := seoData["og_site_name"].(string); ok {
|
||||||
|
settings.OGSiteName = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analytics
|
||||||
|
if analyticsData, ok := siteData["analytics"].(map[string]any); ok {
|
||||||
|
if v, ok := analyticsData["google_analytics_id"].(string); ok {
|
||||||
|
settings.GoogleAnalyticsID = v
|
||||||
|
}
|
||||||
|
if v, ok := analyticsData["custom_head_scripts"].(string); ok {
|
||||||
|
settings.CustomHeadScripts = v
|
||||||
|
}
|
||||||
|
if v, ok := analyticsData["custom_body_scripts"].(string); ok {
|
||||||
|
settings.CustomBodyScripts = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branding (generated favicons)
|
||||||
|
if brandingData, ok := siteData["branding"].(map[string]any); ok {
|
||||||
|
if v, ok := brandingData["is_generated"].(bool); ok {
|
||||||
|
settings.Branding.IsGenerated = v
|
||||||
|
}
|
||||||
|
if v, ok := brandingData["favicon_ico"].(string); ok {
|
||||||
|
settings.Branding.FaviconICO = v
|
||||||
|
}
|
||||||
|
if v, ok := brandingData["favicon_16"].(string); ok {
|
||||||
|
settings.Branding.Favicon16 = v
|
||||||
|
}
|
||||||
|
if v, ok := brandingData["favicon_32"].(string); ok {
|
||||||
|
settings.Branding.Favicon32 = v
|
||||||
|
}
|
||||||
|
if v, ok := brandingData["apple_touch_icon"].(string); ok {
|
||||||
|
settings.Branding.AppleTouchIcon = v
|
||||||
|
}
|
||||||
|
if v, ok := brandingData["android_192"].(string); ok {
|
||||||
|
settings.Branding.Android192 = v
|
||||||
|
}
|
||||||
|
if v, ok := brandingData["android_512"].(string); ok {
|
||||||
|
settings.Branding.Android512 = v
|
||||||
|
}
|
||||||
|
if v, ok := brandingData["maskable_512"].(string); ok {
|
||||||
|
settings.Branding.Maskable512 = v
|
||||||
|
}
|
||||||
|
if v, ok := brandingData["master_1024"].(string); ok {
|
||||||
|
settings.Branding.Master1024 = v
|
||||||
|
}
|
||||||
|
if v, ok := brandingData["manifest_url"].(string); ok {
|
||||||
|
settings.Branding.ManifestURL = v
|
||||||
|
}
|
||||||
|
if v, ok := brandingData["theme_color"].(string); ok {
|
||||||
|
settings.Branding.ThemeColor = v
|
||||||
|
}
|
||||||
|
if v, ok := brandingData["svg"].(string); ok {
|
||||||
|
settings.Branding.SVG = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin bypass mode (for showing banner when admin bypasses maintenance/coming_soon)
|
||||||
|
if v, ok := siteData["admin_bypass_mode"].(string); ok {
|
||||||
|
settings.AdminBypassMode = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin editor toolbar
|
||||||
|
if toolbarData, ok := siteData["toolbar"].(map[string]any); ok {
|
||||||
|
settings.Toolbar = ParseToolbarData(toolbarData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI Optimization settings
|
||||||
|
if aiOpt, ok := siteData["aiOptimization"].(map[string]any); ok {
|
||||||
|
if v, ok := aiOpt["llmsTxtEnabled"].(bool); ok {
|
||||||
|
settings.LLMsTxtEnabled = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turnstile bot protection (from site settings)
|
||||||
|
if turnstileData, ok := siteData["turnstile"].(map[string]any); ok {
|
||||||
|
// Only set site key if Turnstile is enabled
|
||||||
|
if enabled, ok := turnstileData["enabled"].(bool); ok && enabled {
|
||||||
|
if v, ok := turnstileData["site_key"].(string); ok {
|
||||||
|
settings.TurnstileSiteKey = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RSS feed auto-discovery (injected by page handler from system page)
|
||||||
|
if v, ok := siteData["rss_feed_url"].(string); ok {
|
||||||
|
settings.RSSFeedURL = v
|
||||||
|
}
|
||||||
|
if v, ok := siteData["rss_feed_title"].(string); ok {
|
||||||
|
settings.RSSFeedTitle = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePageMeta extracts page-level SEO metadata from the document map
|
||||||
|
// These fields override site-level defaults when set
|
||||||
|
func ParsePageMeta(doc map[string]any) PageMeta {
|
||||||
|
meta := PageMeta{}
|
||||||
|
|
||||||
|
// Page-level SEO fields are stored directly in the document root
|
||||||
|
// (matching frontend PageInfo type in web/src/store/editor.ts)
|
||||||
|
if v, ok := doc["metaTitle"].(string); ok {
|
||||||
|
meta.MetaTitle = v
|
||||||
|
}
|
||||||
|
if v, ok := doc["metaDescription"].(string); ok {
|
||||||
|
meta.MetaDescription = v
|
||||||
|
}
|
||||||
|
if v, ok := doc["ogTitle"].(string); ok {
|
||||||
|
meta.OGTitle = v
|
||||||
|
}
|
||||||
|
if v, ok := doc["ogDescription"].(string); ok {
|
||||||
|
meta.OGDescription = v
|
||||||
|
}
|
||||||
|
if v, ok := doc["ogImage"].(string); ok {
|
||||||
|
meta.OGImage = v
|
||||||
|
}
|
||||||
|
if v, ok := doc["twitterTitle"].(string); ok {
|
||||||
|
meta.TwitterTitle = v
|
||||||
|
}
|
||||||
|
if v, ok := doc["twitterDescription"].(string); ok {
|
||||||
|
meta.TwitterDescription = v
|
||||||
|
}
|
||||||
|
if v, ok := doc["twitterImage"].(string); ok {
|
||||||
|
meta.TwitterImage = v
|
||||||
|
}
|
||||||
|
if v, ok := doc["canonicalUrl"].(string); ok {
|
||||||
|
meta.CanonicalURL = v
|
||||||
|
}
|
||||||
|
if v, ok := doc["robotsDirective"].(string); ok {
|
||||||
|
meta.RobotsDirective = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return meta
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseToolbarData extracts toolbar data from the document map
|
||||||
|
func ParseToolbarData(data map[string]any) ToolbarData {
|
||||||
|
toolbar := ToolbarData{}
|
||||||
|
|
||||||
|
if v, ok := data["enabled"].(bool); ok {
|
||||||
|
toolbar.Enabled = v
|
||||||
|
}
|
||||||
|
if v, ok := data["page_id"].(string); ok {
|
||||||
|
if id, err := uuid.Parse(v); err == nil {
|
||||||
|
toolbar.PageID = id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if v, ok := data["page_slug"].(string); ok {
|
||||||
|
toolbar.PageSlug = v
|
||||||
|
}
|
||||||
|
if v, ok := data["page_title"].(string); ok {
|
||||||
|
toolbar.PageTitle = v
|
||||||
|
}
|
||||||
|
if v, ok := data["post_type"].(string); ok {
|
||||||
|
toolbar.PostType = v
|
||||||
|
}
|
||||||
|
if v, ok := data["status"].(string); ok {
|
||||||
|
toolbar.Status = v
|
||||||
|
}
|
||||||
|
if v, ok := data["has_unpublished_changes"].(bool); ok {
|
||||||
|
toolbar.HasUnpublishedChanges = v
|
||||||
|
}
|
||||||
|
if v, ok := data["preview_mode"].(string); ok {
|
||||||
|
toolbar.PreviewMode = v
|
||||||
|
}
|
||||||
|
if v, ok := data["position"].(string); ok {
|
||||||
|
toolbar.Position = v
|
||||||
|
}
|
||||||
|
if v, ok := data["template_name"].(string); ok {
|
||||||
|
toolbar.TemplateName = v
|
||||||
|
}
|
||||||
|
if v, ok := data["author_name"].(string); ok {
|
||||||
|
toolbar.AuthorName = v
|
||||||
|
}
|
||||||
|
if v, ok := data["author_slug"].(string); ok {
|
||||||
|
toolbar.AuthorSlug = v
|
||||||
|
}
|
||||||
|
if v, ok := data["last_modified"].(time.Time); ok {
|
||||||
|
toolbar.LastModified = v
|
||||||
|
}
|
||||||
|
if v, ok := data["edit_url"].(string); ok {
|
||||||
|
toolbar.EditURL = v
|
||||||
|
}
|
||||||
|
if v, ok := data["settings_url"].(string); ok {
|
||||||
|
toolbar.SettingsURL = v
|
||||||
|
}
|
||||||
|
if v, ok := data["history_url"].(string); ok {
|
||||||
|
toolbar.HistoryURL = v
|
||||||
|
}
|
||||||
|
if v, ok := data["analytics_url"].(string); ok {
|
||||||
|
toolbar.AnalyticsURL = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Analytics snapshot
|
||||||
|
if v, ok := data["today_pageviews"].(int64); ok {
|
||||||
|
toolbar.TodayPageviews = v
|
||||||
|
}
|
||||||
|
if v, ok := data["pageviews_trend"].(string); ok {
|
||||||
|
toolbar.PageviewsTrend = v
|
||||||
|
}
|
||||||
|
if v, ok := data["trend_percent"].(int); ok {
|
||||||
|
toolbar.TrendPercent = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blog-specific fields
|
||||||
|
if v, ok := data["reading_time"].(int); ok {
|
||||||
|
toolbar.ReadingTime = v
|
||||||
|
}
|
||||||
|
if v, ok := data["word_count"].(int); ok {
|
||||||
|
toolbar.WordCount = v
|
||||||
|
}
|
||||||
|
if v, ok := data["category_count"].(int); ok {
|
||||||
|
toolbar.CategoryCount = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return toolbar
|
||||||
|
}
|
||||||
|
|
||||||
|
// EffectiveTitle returns the title to use in the <title> tag.
|
||||||
|
// Priority: PageMeta.MetaTitle → Page Title | Site Name → Page Title
|
||||||
|
func (d HeadData) EffectiveTitle() string {
|
||||||
|
if d.PageMeta.MetaTitle != "" {
|
||||||
|
return d.PageMeta.MetaTitle
|
||||||
|
}
|
||||||
|
if d.Settings.OGSiteName != "" && d.Title != "" {
|
||||||
|
return d.Title + " | " + d.Settings.OGSiteName
|
||||||
|
}
|
||||||
|
return d.Title
|
||||||
|
}
|
||||||
|
|
||||||
|
// Head renders the complete <head> element with site settings and plugin extensions.
|
||||||
|
templ Head(data HeadData) {
|
||||||
|
@recordValidationCall(ctx, "Head")
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||||
|
<title>{ data.EffectiveTitle() }</title>
|
||||||
|
if data.CSSHash != "" {
|
||||||
|
<link rel="stylesheet" href={ "/data/styles/styles.css?v=" + data.CSSHash }/>
|
||||||
|
} else {
|
||||||
|
<link rel="stylesheet" href="/data/styles/styles.css"/>
|
||||||
|
}
|
||||||
|
@themeInitScript(data.ThemeMode)
|
||||||
|
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||||
|
if data.Settings.Branding.IsGenerated {
|
||||||
|
// Use generated brand assets
|
||||||
|
<link rel="icon" type="image/x-icon" href={ data.Settings.Branding.FaviconICO }/>
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href={ data.Settings.Branding.Favicon16 }/>
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href={ data.Settings.Branding.Favicon32 }/>
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href={ data.Settings.Branding.AppleTouchIcon }/>
|
||||||
|
<link rel="manifest" href={ data.Settings.Branding.ManifestURL }/>
|
||||||
|
if data.Settings.Branding.ThemeColor != "" {
|
||||||
|
<meta name="theme-color" content={ data.Settings.Branding.ThemeColor }/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Legacy fallback - manual favicon uploads
|
||||||
|
if data.Settings.Favicon != "" {
|
||||||
|
<link rel="icon" href={ data.Settings.Favicon }/>
|
||||||
|
}
|
||||||
|
if data.Settings.AppleTouchIcon != "" {
|
||||||
|
<link rel="apple-touch-icon" href={ data.Settings.AppleTouchIcon }/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// === SEO META TAGS ===
|
||||||
|
// Fallback chain: Page-level → Site defaults
|
||||||
|
|
||||||
|
// Meta description: Page override → Site default
|
||||||
|
if data.PageMeta.MetaDescription != "" {
|
||||||
|
<meta name="description" content={ data.PageMeta.MetaDescription }/>
|
||||||
|
} else if data.Settings.MetaDescription != "" {
|
||||||
|
<meta name="description" content={ data.Settings.MetaDescription }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonical URL (page-level only, no site default)
|
||||||
|
if data.PageMeta.CanonicalURL != "" {
|
||||||
|
<link rel="canonical" href={ data.PageMeta.CanonicalURL }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Robots directive (page-level only - defaults to index,follow if not set)
|
||||||
|
if data.PageMeta.RobotsDirective != "" {
|
||||||
|
<meta name="robots" content={ data.PageMeta.RobotsDirective }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
// === OPEN GRAPH META TAGS ===
|
||||||
|
// og:site_name is always site-level
|
||||||
|
if data.Settings.OGSiteName != "" {
|
||||||
|
<meta property="og:site_name" content={ data.Settings.OGSiteName }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
// og:title: Page OG → Page Meta Title → Page Title
|
||||||
|
if data.PageMeta.OGTitle != "" {
|
||||||
|
<meta property="og:title" content={ data.PageMeta.OGTitle }/>
|
||||||
|
} else if data.PageMeta.MetaTitle != "" {
|
||||||
|
<meta property="og:title" content={ data.PageMeta.MetaTitle }/>
|
||||||
|
} else if data.Title != "" {
|
||||||
|
<meta property="og:title" content={ data.Title }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
// og:description: Page OG → Page Meta Description → Site default
|
||||||
|
if data.PageMeta.OGDescription != "" {
|
||||||
|
<meta property="og:description" content={ data.PageMeta.OGDescription }/>
|
||||||
|
} else if data.PageMeta.MetaDescription != "" {
|
||||||
|
<meta property="og:description" content={ data.PageMeta.MetaDescription }/>
|
||||||
|
} else if data.Settings.MetaDescription != "" {
|
||||||
|
<meta property="og:description" content={ data.Settings.MetaDescription }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
// og:image: Page OG → Site default (resolve media: URLs)
|
||||||
|
if data.PageMeta.OGImage != "" {
|
||||||
|
<meta property="og:image" content={ resolveMediaURL(data.PageMeta.OGImage) }/>
|
||||||
|
} else if data.Settings.DefaultOGImage != "" {
|
||||||
|
<meta property="og:image" content={ resolveMediaURL(data.Settings.DefaultOGImage) }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
// og:type - default to website
|
||||||
|
<meta property="og:type" content="website"/>
|
||||||
|
|
||||||
|
// === TWITTER CARD META TAGS ===
|
||||||
|
if data.Settings.TwitterHandle != "" {
|
||||||
|
<meta name="twitter:site" content={ data.Settings.TwitterHandle }/>
|
||||||
|
}
|
||||||
|
<meta name="twitter:card" content="summary_large_image"/>
|
||||||
|
|
||||||
|
// twitter:title: Page Twitter → Page OG → Page Meta Title → Page Title
|
||||||
|
if data.PageMeta.TwitterTitle != "" {
|
||||||
|
<meta name="twitter:title" content={ data.PageMeta.TwitterTitle }/>
|
||||||
|
} else if data.PageMeta.OGTitle != "" {
|
||||||
|
<meta name="twitter:title" content={ data.PageMeta.OGTitle }/>
|
||||||
|
} else if data.PageMeta.MetaTitle != "" {
|
||||||
|
<meta name="twitter:title" content={ data.PageMeta.MetaTitle }/>
|
||||||
|
} else if data.Title != "" {
|
||||||
|
<meta name="twitter:title" content={ data.Title }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
// twitter:description: Page Twitter → Page OG → Page Meta Description → Site default
|
||||||
|
if data.PageMeta.TwitterDescription != "" {
|
||||||
|
<meta name="twitter:description" content={ data.PageMeta.TwitterDescription }/>
|
||||||
|
} else if data.PageMeta.OGDescription != "" {
|
||||||
|
<meta name="twitter:description" content={ data.PageMeta.OGDescription }/>
|
||||||
|
} else if data.PageMeta.MetaDescription != "" {
|
||||||
|
<meta name="twitter:description" content={ data.PageMeta.MetaDescription }/>
|
||||||
|
} else if data.Settings.MetaDescription != "" {
|
||||||
|
<meta name="twitter:description" content={ data.Settings.MetaDescription }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
// twitter:image: Page Twitter → Page OG → Site default (resolve media: URLs)
|
||||||
|
if data.PageMeta.TwitterImage != "" {
|
||||||
|
<meta name="twitter:image" content={ resolveMediaURL(data.PageMeta.TwitterImage) }/>
|
||||||
|
} else if data.PageMeta.OGImage != "" {
|
||||||
|
<meta name="twitter:image" content={ resolveMediaURL(data.PageMeta.OGImage) }/>
|
||||||
|
} else if data.Settings.DefaultOGImage != "" {
|
||||||
|
<meta name="twitter:image" content={ resolveMediaURL(data.Settings.DefaultOGImage) }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
// === AI CONTENT DISCOVERY ===
|
||||||
|
// llms.txt meta tag for AI crawlers (if enabled)
|
||||||
|
if data.Settings.LLMsTxtEnabled {
|
||||||
|
<link rel="ai-content" type="text/plain" href="/llms.txt"/>
|
||||||
|
<meta name="llms" content="/llms.txt"/>
|
||||||
|
}
|
||||||
|
|
||||||
|
// === RSS/ATOM FEED DISCOVERY ===
|
||||||
|
if data.Settings.RSSFeedURL != "" {
|
||||||
|
<link rel="alternate" type="application/rss+xml" title={ data.Settings.RSSFeedTitle + " (RSS)" } href={ data.Settings.RSSFeedURL }/>
|
||||||
|
<link rel="alternate" type="application/atom+xml" title={ data.Settings.RSSFeedTitle + " (Atom)" } href={ data.Settings.RSSFeedURL + "/atom" }/>
|
||||||
|
}
|
||||||
|
|
||||||
|
if data.Settings.GoogleAnalyticsID != "" {
|
||||||
|
<script async src={ "https://www.googletagmanager.com/gtag/js?id=" + data.Settings.GoogleAnalyticsID }></script>
|
||||||
|
@googleAnalyticsScript(data.Settings.GoogleAnalyticsID)
|
||||||
|
}
|
||||||
|
// Built-in analytics tracking (cookie-less) with nonce for server-side deduplication
|
||||||
|
@analyticsScript(data.PageviewNonce)
|
||||||
|
// Blog post engagement tracking (only rendered for posts with engagement config)
|
||||||
|
@EngagementScript(data.EngagementConfig)
|
||||||
|
for _, style := range data.PluginStyles {
|
||||||
|
<link rel="stylesheet" href={ style }/>
|
||||||
|
}
|
||||||
|
// Theme CSS injected AFTER plugin styles to take precedence
|
||||||
|
if data.ThemeCSS != "" {
|
||||||
|
@themeStyle(data.ThemeCSS)
|
||||||
|
}
|
||||||
|
if data.Settings.CustomHeadScripts != "" {
|
||||||
|
@templ.Raw(data.Settings.CustomHeadScripts)
|
||||||
|
}
|
||||||
|
// Cloudflare Turnstile invisible bot protection
|
||||||
|
if data.Settings.TurnstileSiteKey != "" {
|
||||||
|
@turnstileScript(data.Settings.TurnstileSiteKey)
|
||||||
|
}
|
||||||
|
if data.StructuredData != "" {
|
||||||
|
@structuredDataScript(data.StructuredData)
|
||||||
|
}
|
||||||
|
{ children... }
|
||||||
|
</head>
|
||||||
|
}
|
||||||
|
|
||||||
|
// structuredDataScript renders JSON-LD structured data
|
||||||
|
func structuredDataScript(jsonLD string) templ.Component {
|
||||||
|
return templ.Raw(`<script type="application/ld+json">` + jsonLD + `</script>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// turnstileScript renders the Cloudflare Turnstile invisible bot protection
|
||||||
|
templ turnstileScript(siteKey string) {
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
|
||||||
|
<script data-turnstile-key={ siteKey }>
|
||||||
|
(function(){
|
||||||
|
var siteKey = document.currentScript.getAttribute('data-turnstile-key');
|
||||||
|
if (!siteKey) return;
|
||||||
|
|
||||||
|
// Store active widget IDs per form
|
||||||
|
var formWidgets = new WeakMap();
|
||||||
|
|
||||||
|
// Initialize Turnstile when API is ready
|
||||||
|
function initTurnstile() {
|
||||||
|
if (typeof turnstile === 'undefined') {
|
||||||
|
setTimeout(initTurnstile, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all forms that POST to /api/* endpoints
|
||||||
|
function instrumentForm(form) {
|
||||||
|
// Skip if already instrumented
|
||||||
|
if (formWidgets.has(form)) return;
|
||||||
|
|
||||||
|
var action = form.action || '';
|
||||||
|
var hxPost = form.getAttribute('hx-post') || '';
|
||||||
|
|
||||||
|
// Only protect /api/* POST endpoints
|
||||||
|
if (!action.includes('/api/') && !hxPost.includes('/api/')) return;
|
||||||
|
|
||||||
|
// Skip exempted endpoints
|
||||||
|
var exempted = ['/api/track', '/api/webhooks/'];
|
||||||
|
for (var i = 0; i < exempted.length; i++) {
|
||||||
|
if (action.includes(exempted[i]) || hxPost.includes(exempted[i])) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create container for widget
|
||||||
|
var container = document.createElement('div');
|
||||||
|
container.className = 'cf-turnstile-container';
|
||||||
|
container.style.cssText = 'position:absolute;left:-9999px;';
|
||||||
|
form.appendChild(container);
|
||||||
|
|
||||||
|
// Render invisible widget
|
||||||
|
var widgetId = turnstile.render(container, {
|
||||||
|
sitekey: siteKey,
|
||||||
|
size: 'invisible',
|
||||||
|
callback: function(token) {
|
||||||
|
// Store token in hidden field
|
||||||
|
var input = form.querySelector('input[name="cf-turnstile-response"]');
|
||||||
|
if (!input) {
|
||||||
|
input = document.createElement('input');
|
||||||
|
input.type = 'hidden';
|
||||||
|
input.name = 'cf-turnstile-response';
|
||||||
|
form.appendChild(input);
|
||||||
|
}
|
||||||
|
input.value = token;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
formWidgets.set(form, widgetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instrument existing forms
|
||||||
|
document.querySelectorAll('form').forEach(instrumentForm);
|
||||||
|
|
||||||
|
// Watch for new forms (dynamic content / HTMX)
|
||||||
|
var observer = new MutationObserver(function(mutations) {
|
||||||
|
mutations.forEach(function(mutation) {
|
||||||
|
mutation.addedNodes.forEach(function(node) {
|
||||||
|
if (node.nodeName === 'FORM') {
|
||||||
|
instrumentForm(node);
|
||||||
|
}
|
||||||
|
if (node.querySelectorAll) {
|
||||||
|
node.querySelectorAll('form').forEach(instrumentForm);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, { childList: true, subtree: true });
|
||||||
|
|
||||||
|
// HTMX integration: add token to request parameters
|
||||||
|
document.body.addEventListener('htmx:configRequest', function(evt) {
|
||||||
|
var form = evt.detail.elt.closest('form');
|
||||||
|
if (!form || !formWidgets.has(form)) return;
|
||||||
|
|
||||||
|
var widgetId = formWidgets.get(form);
|
||||||
|
var token = turnstile.getResponse(widgetId);
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
evt.detail.parameters['cf-turnstile-response'] = token;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset widgets after HTMX swap
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||||
|
if (evt.detail.target.querySelectorAll) {
|
||||||
|
evt.detail.target.querySelectorAll('form').forEach(instrumentForm);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initialization
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initTurnstile);
|
||||||
|
} else {
|
||||||
|
initTurnstile();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminBypassBanner renders a banner when admin is bypassing maintenance/coming_soon mode
|
||||||
|
// This should be rendered at the very start of the <body> to push down all content
|
||||||
|
templ AdminBypassBanner(settings SiteSettingsData) {
|
||||||
|
if settings.AdminBypassMode != "" {
|
||||||
|
<div class="bg-warning text-warning-foreground px-4 py-2 text-center text-sm font-medium relative z-[9999]">
|
||||||
|
<div class="max-w-7xl mx-auto flex items-center justify-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
if settings.AdminBypassMode == "maintenance" {
|
||||||
|
<span>
|
||||||
|
<strong>Maintenance Mode Active</strong> — You're seeing the normal site because you're logged in as admin.
|
||||||
|
<a href="/admin/settings?tab=status" class="underline hover:no-underline">Manage</a>
|
||||||
|
</span>
|
||||||
|
} else if settings.AdminBypassMode == "coming_soon" {
|
||||||
|
<span>
|
||||||
|
<strong>Coming Soon Mode Active</strong> — You're seeing the normal site because you're logged in as admin.
|
||||||
|
<a href="/admin/settings?tab=status" class="underline hover:no-underline">Manage</a>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BodyEnd renders custom scripts and admin toolbar before </body>
|
||||||
|
templ BodyEnd(settings SiteSettingsData) {
|
||||||
|
@recordValidationCall(ctx, "BodyEnd")
|
||||||
|
if settings.CustomBodyScripts != "" {
|
||||||
|
@templ.Raw(settings.CustomBodyScripts)
|
||||||
|
}
|
||||||
|
// Render admin editor toolbar if enabled (auto-injects for all templates)
|
||||||
|
@AdminEditorToolbar(settings.Toolbar)
|
||||||
|
{ children... }
|
||||||
|
}
|
||||||
|
|
||||||
|
// themeStyle renders the theme CSS variables
|
||||||
|
func themeStyleComponent(css string) templ.Component {
|
||||||
|
return templ.Raw(`<style id="theme-variables">` + css + `</style>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
templ themeStyle(css string) {
|
||||||
|
@themeStyleComponent(css)
|
||||||
|
}
|
||||||
|
|
||||||
|
// googleAnalyticsScript renders the GA4 inline script
|
||||||
|
func googleAnalyticsScript(gaID string) templ.Component {
|
||||||
|
return templ.Raw(`<script>window.dataLayer = window.dataLayer || [];function gtag(){dataLayer.push(arguments);}gtag('js', new Date());gtag('config', '` + gaID + `');</script>`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// analyticsScript renders the built-in analytics tracking script (cookie-less)
|
||||||
|
// nonce is used for deduplication with server-side tracking
|
||||||
|
templ analyticsScript(nonce string) {
|
||||||
|
<script data-nonce={ nonce }>
|
||||||
|
(function(){
|
||||||
|
// Don't track admin pages
|
||||||
|
if(location.pathname.startsWith('/admin'))return;
|
||||||
|
|
||||||
|
// CRITICAL: Capture nonce immediately while document.currentScript is valid
|
||||||
|
// document.currentScript is only available during script parsing, not later
|
||||||
|
var _nonce = document.currentScript ? document.currentScript.getAttribute('data-nonce') : '';
|
||||||
|
|
||||||
|
// Persistent session ID (survives page navigation within session)
|
||||||
|
var sid=sessionStorage.getItem('_bn_sid');
|
||||||
|
if(!sid){sid=crypto.randomUUID();sessionStorage.setItem('_bn_sid',sid);}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Advanced Bot Detection - Client-Side Signals
|
||||||
|
// See docs/BOT_DETECTION.md for full documentation
|
||||||
|
// ============================================================
|
||||||
|
var botSignals={},botScore=0;
|
||||||
|
try{
|
||||||
|
// 1. WebDriver property (30 points) - automation frameworks set this
|
||||||
|
botSignals.webdriver=!!navigator.webdriver;
|
||||||
|
if(botSignals.webdriver)botScore+=30;
|
||||||
|
|
||||||
|
// 2. CDP Detection (25 points) - detects Puppeteer/Playwright/Selenium
|
||||||
|
botSignals.cdp=false;
|
||||||
|
try{
|
||||||
|
var e=new Error();
|
||||||
|
Object.defineProperty(e,'stack',{get:function(){botSignals.cdp=true;}});
|
||||||
|
console.debug(e);
|
||||||
|
if(botSignals.cdp)botScore+=25;
|
||||||
|
}catch(x){}
|
||||||
|
|
||||||
|
// 3. Plugin count (15 points) - headless often has no plugins
|
||||||
|
botSignals.plugins=navigator.plugins?navigator.plugins.length:0;
|
||||||
|
if(botSignals.plugins===0)botScore+=15;
|
||||||
|
|
||||||
|
// 4. Languages (15 points) - headless often has empty languages
|
||||||
|
botSignals.languages=navigator.languages?navigator.languages.length:0;
|
||||||
|
if(botSignals.languages===0)botScore+=15;
|
||||||
|
|
||||||
|
// 5. Chrome runtime (10 points) - real Chrome has chrome.runtime
|
||||||
|
botSignals.chromeRuntime=!!(window.chrome&&window.chrome.runtime);
|
||||||
|
if(window.chrome&&!window.chrome.runtime)botScore+=10;
|
||||||
|
|
||||||
|
// 6. WebGL renderer (20 points) - headless uses SwiftShader/llvmpipe
|
||||||
|
try{
|
||||||
|
var c=document.createElement('canvas');
|
||||||
|
var gl=c.getContext('webgl')||c.getContext('experimental-webgl');
|
||||||
|
if(gl){
|
||||||
|
var dbg=gl.getExtension('WEBGL_debug_renderer_info');
|
||||||
|
if(dbg){
|
||||||
|
botSignals.webglRenderer=gl.getParameter(dbg.UNMASKED_RENDERER_WEBGL)||'';
|
||||||
|
var r=botSignals.webglRenderer.toLowerCase();
|
||||||
|
if(r.includes('swiftshader')||r.includes('llvmpipe')||r.includes('software'))botScore+=20;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}catch(x){}
|
||||||
|
|
||||||
|
// 7. Permission anomalies (15 points) - inconsistent API states
|
||||||
|
if(window.Notification&&navigator.permissions){
|
||||||
|
navigator.permissions.query({name:'notifications'}).then(function(p){
|
||||||
|
if(p.state==='denied'&&Notification.permission==='default'){
|
||||||
|
botSignals.permissionAnomaly=true;
|
||||||
|
botScore+=15;
|
||||||
|
}
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Headless in Client Hints (20 points)
|
||||||
|
if(navigator.userAgentData&&navigator.userAgentData.getHighEntropyValues){
|
||||||
|
navigator.userAgentData.getHighEntropyValues(['fullVersionList','platform']).then(function(h){
|
||||||
|
if(h.brands&&h.brands.some(function(b){return b.brand.toLowerCase().includes('headless');})){
|
||||||
|
botSignals.headlessHints=true;
|
||||||
|
botScore+=20;
|
||||||
|
}
|
||||||
|
}).catch(function(){});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. Automation variables (10 points each)
|
||||||
|
if(window._phantom||window.__nightmare||window.callPhantom){botSignals.phantomjs=true;botScore+=10;}
|
||||||
|
if(window.__selenium_unwrapped||window.__webdriver_evaluate||document.__selenium_evaluate){botSignals.selenium=true;botScore+=10;}
|
||||||
|
if(window.__fxdriver_evaluate||window.__webdriver_script_fn){botSignals.selenium=true;botScore+=10;}
|
||||||
|
|
||||||
|
// 10. Broken image dimensions (10 points) - headless often fails to render
|
||||||
|
try{
|
||||||
|
var img=new Image();
|
||||||
|
img.src='data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
|
||||||
|
if(img.height===0){botSignals.brokenImage=true;botScore+=10;}
|
||||||
|
}catch(x){}
|
||||||
|
|
||||||
|
// Cap at 100
|
||||||
|
botScore=Math.min(botScore,100);
|
||||||
|
botSignals.score=botScore;
|
||||||
|
}catch(x){botSignals.error=x.message;}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Web Vitals collection
|
||||||
|
// ============================================================
|
||||||
|
var _lcp=0,_fcp=0,_cls=0,_ttfb=0;
|
||||||
|
if('PerformanceObserver'in window){
|
||||||
|
try{
|
||||||
|
new PerformanceObserver(function(l){var e=l.getEntries().pop();if(e)_lcp=Math.round(e.startTime);}).observe({type:'largest-contentful-paint',buffered:true});
|
||||||
|
new PerformanceObserver(function(l){l.getEntries().forEach(function(e){if(e.name==='first-contentful-paint')_fcp=Math.round(e.startTime);});}).observe({type:'paint',buffered:true});
|
||||||
|
new PerformanceObserver(function(l){l.getEntries().forEach(function(e){if(!e.hadRecentInput)_cls+=e.value;});}).observe({type:'layout-shift',buffered:true});
|
||||||
|
}catch(e){}
|
||||||
|
}
|
||||||
|
try{var nav=performance.getEntriesByType('navigation')[0];if(nav)_ttfb=Math.round(nav.responseStart);}catch(e){}
|
||||||
|
|
||||||
|
// Scroll depth tracking
|
||||||
|
var _maxScroll=0;
|
||||||
|
function updateScroll(){
|
||||||
|
var h=document.documentElement.scrollHeight-window.innerHeight;
|
||||||
|
if(h>0){var pct=Math.round((window.scrollY/h)*100);if(pct>_maxScroll)_maxScroll=pct;}
|
||||||
|
}
|
||||||
|
window.addEventListener('scroll',updateScroll,{passive:true});
|
||||||
|
|
||||||
|
// Send beacon with fallback to fetch
|
||||||
|
function send(url,data){
|
||||||
|
var payload=JSON.stringify(data);
|
||||||
|
if(!navigator.sendBeacon(url,payload)){
|
||||||
|
fetch(url,{method:'POST',body:payload,keepalive:true}).catch(function(){});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track pageview with server-side deduplication nonce
|
||||||
|
function t(){
|
||||||
|
var d={
|
||||||
|
p:location.pathname+location.search,
|
||||||
|
r:document.referrer,
|
||||||
|
sw:screen.width,
|
||||||
|
sh:screen.height,
|
||||||
|
vw:window.innerWidth,
|
||||||
|
vh:window.innerHeight,
|
||||||
|
pr:window.devicePixelRatio||1,
|
||||||
|
tz:new Date().getTimezoneOffset(),
|
||||||
|
cs:window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light',
|
||||||
|
// Bot detection signals
|
||||||
|
bs:botSignals,
|
||||||
|
bsc:botScore
|
||||||
|
};
|
||||||
|
if(navigator.connection){
|
||||||
|
d.ct=navigator.connection.effectiveType||'';
|
||||||
|
d.cd=navigator.connection.downlink||0;
|
||||||
|
}
|
||||||
|
// Use the nonce captured at parse time for server-side deduplication
|
||||||
|
if(_nonce)d.n=_nonce;
|
||||||
|
var u=new URLSearchParams(location.search);
|
||||||
|
if(u.get('s'))d.st=u.get('s');
|
||||||
|
send('/api/track',d);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit beacon with time on page and web vitals
|
||||||
|
var pageStart=Date.now();
|
||||||
|
function sendExit(){
|
||||||
|
send('/api/track/exit',{
|
||||||
|
sid:sid,
|
||||||
|
top:Date.now()-pageStart,
|
||||||
|
sd:_maxScroll,
|
||||||
|
lcp:_lcp,
|
||||||
|
fcp:_fcp,
|
||||||
|
cls:Math.round(_cls*1000)/1000,
|
||||||
|
ttfb:_ttfb
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener('visibilitychange',function(){
|
||||||
|
if(document.visibilityState==='hidden')sendExit();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Track on load
|
||||||
|
if(document.readyState==='complete'){t();}else{window.addEventListener('load',t);}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
|
||||||
|
func themeInitScript(themeMode string) templ.Component {
|
||||||
|
switch themeMode {
|
||||||
|
case "light", "dark", "system":
|
||||||
|
default:
|
||||||
|
themeMode = "light"
|
||||||
|
}
|
||||||
|
return templ.Raw(`<script>
|
||||||
|
(function(){
|
||||||
|
var c=document.cookie.match(/(?:^|; )bn-theme=([^;]*)/);
|
||||||
|
var t=c?c[1]:localStorage.getItem('bn-theme');
|
||||||
|
if(!t)t='` + themeMode + `';
|
||||||
|
var h=document.documentElement;
|
||||||
|
if(t==='system')t=window.matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light';
|
||||||
|
if(t==='dark')h.classList.add('dark');
|
||||||
|
else h.classList.remove('dark');
|
||||||
|
})();
|
||||||
|
</script>`)
|
||||||
|
}
|
||||||
1470
templates/bn/head_templ.go
Normal file
1470
templates/bn/head_templ.go
Normal file
File diff suppressed because one or more lines are too long
528
templates/bn/toolbar.templ
Normal file
528
templates/bn/toolbar.templ
Normal file
@ -0,0 +1,528 @@
|
|||||||
|
package bn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToolbarData contains data for the admin editor toolbar
|
||||||
|
type ToolbarData struct {
|
||||||
|
Enabled bool
|
||||||
|
PageID uuid.UUID
|
||||||
|
PageSlug string
|
||||||
|
PageTitle string
|
||||||
|
PostType string // "page", "post", "master", "system"
|
||||||
|
Status string // "published", "draft", "scheduled"
|
||||||
|
HasUnpublishedChanges bool
|
||||||
|
PreviewMode string // "published" or "draft"
|
||||||
|
Position string // "tl", "tc", "tr", "bl", "bc", "br" (default: "tr")
|
||||||
|
ScheduledAt *time.Time
|
||||||
|
TemplateName string
|
||||||
|
AuthorName string
|
||||||
|
LastModified time.Time
|
||||||
|
EditURL string
|
||||||
|
SettingsURL string
|
||||||
|
HistoryURL string
|
||||||
|
AnalyticsURL string
|
||||||
|
|
||||||
|
// Analytics snapshot
|
||||||
|
TodayPageviews int64
|
||||||
|
PageviewsTrend string // "up", "down", "flat"
|
||||||
|
TrendPercent int
|
||||||
|
|
||||||
|
// Blog-specific fields
|
||||||
|
ReadingTime int // minutes
|
||||||
|
WordCount int
|
||||||
|
CategoryCount int
|
||||||
|
AuthorSlug string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusBadgeClass returns the Tailwind classes for the status badge
|
||||||
|
func (t ToolbarData) StatusBadgeClass() string {
|
||||||
|
switch t.Status {
|
||||||
|
case "published":
|
||||||
|
return "bg-success text-success-foreground"
|
||||||
|
case "scheduled":
|
||||||
|
return "bg-info text-info-foreground"
|
||||||
|
default:
|
||||||
|
return "bg-warning text-warning-foreground"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusLabel returns a human-readable status label
|
||||||
|
func (t ToolbarData) StatusLabel() string {
|
||||||
|
switch t.Status {
|
||||||
|
case "published":
|
||||||
|
return "Published"
|
||||||
|
case "scheduled":
|
||||||
|
return "Scheduled"
|
||||||
|
default:
|
||||||
|
return "Draft"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostTypeLabel returns a human-readable post type label
|
||||||
|
func (t ToolbarData) PostTypeLabel() string {
|
||||||
|
switch t.PostType {
|
||||||
|
case "post":
|
||||||
|
return "Post"
|
||||||
|
case "master":
|
||||||
|
return "Master"
|
||||||
|
case "system":
|
||||||
|
return "System"
|
||||||
|
default:
|
||||||
|
return "Page"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPreviewMode returns true if currently viewing draft/preview
|
||||||
|
func (t ToolbarData) IsPreviewMode() bool {
|
||||||
|
return t.PreviewMode == "draft"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanPublish returns true if the publish button should be enabled
|
||||||
|
func (t ToolbarData) CanPublish() bool {
|
||||||
|
return t.HasUnpublishedChanges
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastModifiedFormatted returns the last modified time in a readable format
|
||||||
|
func (t ToolbarData) LastModifiedFormatted() string {
|
||||||
|
return t.LastModified.Format("Jan 2, 2006 3:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTopPosition returns true if toolbar should be at top (for legacy compatibility)
|
||||||
|
func (t ToolbarData) IsTopPosition() bool {
|
||||||
|
return t.Position == "tl" || t.Position == "tc" || t.Position == "tr" || t.Position == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBottomPosition returns true if toolbar is at bottom
|
||||||
|
func (t ToolbarData) IsBottomPosition() bool {
|
||||||
|
return t.Position == "bl" || t.Position == "bc" || t.Position == "br"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLeftPosition returns true if toolbar is on the left side
|
||||||
|
func (t ToolbarData) IsLeftPosition() bool {
|
||||||
|
return t.Position == "tl" || t.Position == "bl"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRightPosition returns true if toolbar is on the right side
|
||||||
|
func (t ToolbarData) IsRightPosition() bool {
|
||||||
|
return t.Position == "tr" || t.Position == "br" || t.Position == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCenterPosition returns true if toolbar is centered
|
||||||
|
func (t ToolbarData) IsCenterPosition() bool {
|
||||||
|
return t.Position == "tc" || t.Position == "bc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropdownDirection returns "up" or "down" based on toolbar vertical position
|
||||||
|
func (t ToolbarData) DropdownDirection() string {
|
||||||
|
if t.IsBottomPosition() {
|
||||||
|
return "up"
|
||||||
|
}
|
||||||
|
return "down"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropdownAlign returns "left", "center", or "right" based on toolbar horizontal position
|
||||||
|
func (t ToolbarData) DropdownAlign() string {
|
||||||
|
if t.IsLeftPosition() {
|
||||||
|
return "left"
|
||||||
|
} else if t.IsCenterPosition() {
|
||||||
|
return "center"
|
||||||
|
}
|
||||||
|
return "right"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PositionClasses returns the positioning classes for the floating pill
|
||||||
|
func (t ToolbarData) PositionClasses() string {
|
||||||
|
switch t.Position {
|
||||||
|
case "tl":
|
||||||
|
return "top-4 left-4"
|
||||||
|
case "tc":
|
||||||
|
return "top-4 left-1/2 -translate-x-1/2"
|
||||||
|
case "bl":
|
||||||
|
return "bottom-4 left-4"
|
||||||
|
case "bc":
|
||||||
|
return "bottom-4 left-1/2 -translate-x-1/2"
|
||||||
|
case "br":
|
||||||
|
return "bottom-4 right-4"
|
||||||
|
default: // "tr" or empty
|
||||||
|
return "top-4 right-4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlogPost returns true if this is a blog post
|
||||||
|
func (t ToolbarData) IsBlogPost() bool {
|
||||||
|
return t.PostType == "post"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendIcon returns the trend icon for pageviews
|
||||||
|
func (t ToolbarData) TrendIcon() string {
|
||||||
|
switch t.PageviewsTrend {
|
||||||
|
case "up":
|
||||||
|
return "↑"
|
||||||
|
case "down":
|
||||||
|
return "↓"
|
||||||
|
default:
|
||||||
|
return "→"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendColorClass returns the color class for the trend indicator
|
||||||
|
func (t ToolbarData) TrendColorClass() string {
|
||||||
|
switch t.PageviewsTrend {
|
||||||
|
case "up":
|
||||||
|
return "text-success"
|
||||||
|
case "down":
|
||||||
|
return "text-destructive"
|
||||||
|
default:
|
||||||
|
return "text-muted-foreground"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminEditorToolbar renders the floating pill toolbar for admins on public pages
|
||||||
|
// Uses inverted color scheme - dark on light backgrounds, light on dark backgrounds
|
||||||
|
templ AdminEditorToolbar(data ToolbarData) {
|
||||||
|
if data.Enabled {
|
||||||
|
<div
|
||||||
|
id="bn-admin-toolbar"
|
||||||
|
class={ "bn-toolbar fixed z-[9999] flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-full backdrop-blur-md transition-all duration-300", data.PositionClasses() }
|
||||||
|
>
|
||||||
|
<style>
|
||||||
|
/* Toolbar: dark by default (for light pages), light when page has .dark class */
|
||||||
|
.bn-toolbar {
|
||||||
|
--bn-toolbar-bg: rgba(24, 24, 27, 0.95);
|
||||||
|
--bn-toolbar-fg: #fafafa;
|
||||||
|
--bn-toolbar-muted: #a1a1aa;
|
||||||
|
--bn-toolbar-border: rgba(63, 63, 70, 0.8);
|
||||||
|
--bn-toolbar-hover: rgba(63, 63, 70, 0.5);
|
||||||
|
--bn-toolbar-primary-bg: #3b82f6;
|
||||||
|
--bn-toolbar-primary-fg: #ffffff;
|
||||||
|
background: var(--bn-toolbar-bg);
|
||||||
|
color: var(--bn-toolbar-fg);
|
||||||
|
border: 1px solid var(--bn-toolbar-border);
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
/* Light toolbar when page theme is dark (.dark class on html or body) */
|
||||||
|
.dark .bn-toolbar, html.dark .bn-toolbar, body.dark .bn-toolbar {
|
||||||
|
--bn-toolbar-bg: rgba(250, 250, 250, 0.95);
|
||||||
|
--bn-toolbar-fg: #18181b;
|
||||||
|
--bn-toolbar-muted: #71717a;
|
||||||
|
--bn-toolbar-border: rgba(228, 228, 231, 0.8);
|
||||||
|
--bn-toolbar-hover: rgba(228, 228, 231, 0.5);
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.25);
|
||||||
|
}
|
||||||
|
.bn-toolbar .bn-toolbar-text { color: var(--bn-toolbar-fg); }
|
||||||
|
.bn-toolbar .bn-toolbar-muted { color: var(--bn-toolbar-muted); }
|
||||||
|
.bn-toolbar .bn-toolbar-divider { background: var(--bn-toolbar-border); }
|
||||||
|
.bn-toolbar .bn-toolbar-hover:hover { background: var(--bn-toolbar-hover); }
|
||||||
|
.bn-toolbar .bn-toolbar-primary {
|
||||||
|
background: var(--bn-toolbar-primary-bg);
|
||||||
|
color: var(--bn-toolbar-primary-fg);
|
||||||
|
}
|
||||||
|
.bn-toolbar .bn-toolbar-primary:hover {
|
||||||
|
background: color-mix(in srgb, var(--bn-toolbar-primary-bg) 90%, black);
|
||||||
|
}
|
||||||
|
/* Dropdown styling - inherits toolbar vars */
|
||||||
|
.bn-toolbar-dropdown {
|
||||||
|
background: var(--bn-toolbar-bg);
|
||||||
|
color: var(--bn-toolbar-fg);
|
||||||
|
border: 1px solid var(--bn-toolbar-border);
|
||||||
|
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.bn-toolbar-dropdown .bn-dd-border { border-color: var(--bn-toolbar-border); }
|
||||||
|
.bn-toolbar-dropdown .bn-dd-text { color: var(--bn-toolbar-fg); }
|
||||||
|
.bn-toolbar-dropdown .bn-dd-muted { color: var(--bn-toolbar-muted); }
|
||||||
|
.bn-toolbar-dropdown .bn-dd-hover:hover { background: var(--bn-toolbar-hover); }
|
||||||
|
.bn-toolbar-dropdown .bn-dd-link { color: var(--bn-toolbar-primary-bg); }
|
||||||
|
.bn-toolbar-dropdown .bn-dd-link:hover { text-decoration: underline; }
|
||||||
|
/* Dropdown positioning based on data attributes */
|
||||||
|
.bn-dropdown-container { position: absolute; }
|
||||||
|
.bn-dropdown-container[data-direction="up"] { bottom: 100%; margin-bottom: 0.5rem; }
|
||||||
|
.bn-dropdown-container[data-direction="down"] { top: 100%; margin-top: 0.5rem; }
|
||||||
|
.bn-dropdown-container[data-align="left"] { left: 0; }
|
||||||
|
.bn-dropdown-container[data-align="right"] { right: 0; }
|
||||||
|
.bn-dropdown-container[data-align="center"] { left: 50%; transform: translateX(-50%); }
|
||||||
|
</style>
|
||||||
|
<!-- Edit button -->
|
||||||
|
<a
|
||||||
|
href={ templ.SafeURL(data.EditURL) }
|
||||||
|
class="bn-toolbar-primary inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full transition-colors"
|
||||||
|
title="Edit this page"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Edit</span>
|
||||||
|
</a>
|
||||||
|
<!-- Status indicator -->
|
||||||
|
<div class="flex items-center gap-1.5 px-2" title={ data.StatusLabel() }>
|
||||||
|
<span class={ "w-2 h-2 rounded-full", templ.KV("bg-success", data.Status == "published"), templ.KV("bg-warning", data.Status == "draft"), templ.KV("bg-info", data.Status == "scheduled"), templ.KV("animate-pulse ring-2 ring-warning/50", data.HasUnpublishedChanges) }></span>
|
||||||
|
<span class="bn-toolbar-muted text-xs hidden sm:inline">{ data.StatusLabel() }</span>
|
||||||
|
</div>
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="bn-toolbar-divider w-px h-5"></div>
|
||||||
|
<!-- Preview toggle - compact -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={ "bn-toolbar-hover inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors", templ.KV("!bg-info/20 text-info", data.IsPreviewMode()), templ.KV("bn-toolbar-muted", !data.IsPreviewMode()) }
|
||||||
|
onclick={ togglePreviewMode(data.IsPreviewMode()) }
|
||||||
|
title={ func() string { if data.IsPreviewMode() { return "Viewing draft - click to view published" } else { return "Viewing published - click to preview draft" } }() }
|
||||||
|
>
|
||||||
|
if data.IsPreviewMode() {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M10 12a2 2 0 100-4 2 2 0 000 4z"></path>
|
||||||
|
<path fill-rule="evenodd" d="M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Draft</span>
|
||||||
|
} else {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Live</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<!-- Blog-specific: Reading time -->
|
||||||
|
if data.IsBlogPost() && data.ReadingTime > 0 {
|
||||||
|
<div class="bn-toolbar-muted hidden md:flex items-center gap-1 px-2 text-xs" title="Reading time">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
{ fmt.Sprintf("%d min", data.ReadingTime) }
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Publish button (if changes) -->
|
||||||
|
if data.CanPublish() {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-success hover:bg-success/90 text-success-foreground rounded-full transition-colors"
|
||||||
|
hx-post={ fmt.Sprintf("/toolbar/publish/%s", data.PageID) }
|
||||||
|
hx-target="#bn-admin-toolbar"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Publish this page?"
|
||||||
|
title="Publish changes"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Publish</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<!-- More menu -->
|
||||||
|
<div class="relative" id="more-menu-container">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bn-toolbar-hover bn-toolbar-muted inline-flex items-center justify-center w-8 h-8 rounded-full transition-colors"
|
||||||
|
hx-get={ fmt.Sprintf("/toolbar/page-info/%s", data.PageID) }
|
||||||
|
hx-target="#page-info-dropdown"
|
||||||
|
hx-trigger="click"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
title="More options"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
id="page-info-dropdown"
|
||||||
|
class="bn-dropdown-container"
|
||||||
|
data-direction={ data.DropdownDirection() }
|
||||||
|
data-align={ data.DropdownAlign() }
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Click outside to close dropdowns -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
if (!e.target.closest('#more-menu-container')) {
|
||||||
|
document.getElementById('page-info-dropdown').innerHTML = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageInfoDropdown renders the page info and quick actions dropdown
|
||||||
|
templ PageInfoDropdown(data ToolbarData) {
|
||||||
|
<div class="bn-toolbar-dropdown rounded-lg py-2 min-w-[260px] max-w-[320px]">
|
||||||
|
<!-- Analytics snapshot -->
|
||||||
|
if data.TodayPageviews > 0 || data.PageviewsTrend != "" {
|
||||||
|
<div class="bn-dd-border px-3 py-2 border-b">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="bn-dd-text text-sm font-medium">{ fmt.Sprintf("%d", data.TodayPageviews) } views today</span>
|
||||||
|
</div>
|
||||||
|
if data.TrendPercent != 0 {
|
||||||
|
<span class={ "text-xs font-medium", data.TrendColorClass() }>
|
||||||
|
{ data.TrendIcon() } { fmt.Sprintf("%d%%", abs(data.TrendPercent)) }
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Blog-specific: Author & Reading time -->
|
||||||
|
if data.IsBlogPost() {
|
||||||
|
<div class="bn-dd-border px-3 py-2 border-b">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
if data.AuthorName != "" {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
if data.AuthorSlug != "" {
|
||||||
|
<a href={ templ.SafeURL("/author/" + data.AuthorSlug) } class="bn-dd-link">{ data.AuthorName }</a>
|
||||||
|
} else {
|
||||||
|
<span class="bn-dd-text">{ data.AuthorName }</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
if data.ReadingTime > 0 {
|
||||||
|
<span class="bn-dd-muted text-xs">{ fmt.Sprintf("%d min read", data.ReadingTime) }</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
if data.WordCount > 0 {
|
||||||
|
<div class="bn-dd-muted text-xs mt-1">{ fmt.Sprintf("%d words", data.WordCount) }</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<!-- Page info -->
|
||||||
|
<div class="bn-dd-border bn-dd-muted px-3 py-2 border-b text-xs">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span>Template</span>
|
||||||
|
<span class="bn-dd-text">{ data.TemplateName }</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between mt-1">
|
||||||
|
<span>Modified</span>
|
||||||
|
<span class="bn-dd-text">{ data.LastModifiedFormatted() }</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Quick actions -->
|
||||||
|
<div class="py-1">
|
||||||
|
<a href={ templ.SafeURL(data.SettingsURL) } class="bn-dd-hover bn-dd-text flex items-center gap-2 px-3 py-1.5 text-sm transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
<a href={ templ.SafeURL(data.HistoryURL) } class="bn-dd-hover bn-dd-text flex items-center gap-2 px-3 py-1.5 text-sm transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
History
|
||||||
|
</a>
|
||||||
|
<a href={ templ.SafeURL(data.AnalyticsURL) } class="bn-dd-hover bn-dd-text flex items-center gap-2 px-3 py-1.5 text-sm transition-colors">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z"></path>
|
||||||
|
</svg>
|
||||||
|
Analytics
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- Sharing -->
|
||||||
|
<div class="bn-dd-border border-t py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bn-dd-hover bn-dd-text w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors"
|
||||||
|
onclick={ copyPageURL(data.PageSlug) }
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"></path>
|
||||||
|
</svg>
|
||||||
|
Copy link
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bn-dd-hover bn-dd-text w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors"
|
||||||
|
hx-post={ fmt.Sprintf("/toolbar/share-preview/%s", data.PageID) }
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
hx-target="this"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="bn-dd-muted h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path d="M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z"></path>
|
||||||
|
</svg>
|
||||||
|
Share preview link
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<!-- Position selector -->
|
||||||
|
<div class="bn-dd-border border-t px-3 py-2">
|
||||||
|
<div class="bn-dd-muted text-xs mb-2">Move toolbar</div>
|
||||||
|
<div class="grid grid-cols-3 gap-1">
|
||||||
|
@positionButton(data.PageID, "tl", data.Position, "Top left")
|
||||||
|
@positionButton(data.PageID, "tc", data.Position, "Top center")
|
||||||
|
@positionButton(data.PageID, "tr", data.Position, "Top right")
|
||||||
|
@positionButton(data.PageID, "bl", data.Position, "Bottom left")
|
||||||
|
@positionButton(data.PageID, "bc", data.Position, "Bottom center")
|
||||||
|
@positionButton(data.PageID, "br", data.Position, "Bottom right")
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Danger zone -->
|
||||||
|
if data.HasUnpublishedChanges {
|
||||||
|
<div class="bn-dd-border border-t py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full flex items-center gap-2 px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
hx-post={ fmt.Sprintf("/toolbar/discard/%s", data.PageID) }
|
||||||
|
hx-target="#bn-admin-toolbar"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-confirm="Discard all unpublished changes? This cannot be undone."
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Discard changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
// positionButton renders a position selector button
|
||||||
|
templ positionButton(pageID uuid.UUID, pos string, currentPos string, label string) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={ "w-8 h-6 rounded border transition-colors flex items-center justify-center", templ.KV("bg-info border-info", pos == currentPos || (currentPos == "" && pos == "tr")), templ.KV("bn-dd-border bn-dd-hover", pos != currentPos && !(currentPos == "" && pos == "tr")) }
|
||||||
|
hx-post={ fmt.Sprintf("/toolbar/set-position/%s?pos=%s", pageID, pos) }
|
||||||
|
hx-target="#bn-admin-toolbar"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
title={ label }
|
||||||
|
>
|
||||||
|
<span class={ "w-1.5 h-1.5 rounded-full", templ.KV("bg-info-foreground", pos == currentPos || (currentPos == "" && pos == "tr")), templ.KV("bn-dd-muted", pos != currentPos && !(currentPos == "" && pos == "tr")) }></span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
|
||||||
|
// abs returns the absolute value of an integer
|
||||||
|
func abs(n int) int {
|
||||||
|
if n < 0 {
|
||||||
|
return -n
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyPageURL generates the script to copy page URL
|
||||||
|
script copyPageURL(slug string) {
|
||||||
|
const url = window.location.origin + slug;
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
// Show brief feedback - could enhance with toast later
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg> Copied!';
|
||||||
|
setTimeout(() => { btn.innerHTML = originalText; }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// togglePreviewMode toggles between preview (draft) and published mode via URL
|
||||||
|
script togglePreviewMode(isCurrentlyPreview bool) {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
if (isCurrentlyPreview) {
|
||||||
|
// Currently previewing, remove preview param
|
||||||
|
url.searchParams.delete('preview');
|
||||||
|
} else {
|
||||||
|
// Not previewing, add preview param
|
||||||
|
url.searchParams.set('preview', '1');
|
||||||
|
}
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}
|
||||||
969
templates/bn/toolbar_templ.go
Normal file
969
templates/bn/toolbar_templ.go
Normal file
@ -0,0 +1,969 @@
|
|||||||
|
// Code generated by templ - DO NOT EDIT.
|
||||||
|
|
||||||
|
// templ: version: v0.3.1001
|
||||||
|
package bn
|
||||||
|
|
||||||
|
//lint:file-ignore SA4006 This context is only used if a nested component is present.
|
||||||
|
|
||||||
|
import "github.com/a-h/templ"
|
||||||
|
import templruntime "github.com/a-h/templ/runtime"
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToolbarData contains data for the admin editor toolbar
|
||||||
|
type ToolbarData struct {
|
||||||
|
Enabled bool
|
||||||
|
PageID uuid.UUID
|
||||||
|
PageSlug string
|
||||||
|
PageTitle string
|
||||||
|
PostType string // "page", "post", "master", "system"
|
||||||
|
Status string // "published", "draft", "scheduled"
|
||||||
|
HasUnpublishedChanges bool
|
||||||
|
PreviewMode string // "published" or "draft"
|
||||||
|
Position string // "tl", "tc", "tr", "bl", "bc", "br" (default: "tr")
|
||||||
|
ScheduledAt *time.Time
|
||||||
|
TemplateName string
|
||||||
|
AuthorName string
|
||||||
|
LastModified time.Time
|
||||||
|
EditURL string
|
||||||
|
SettingsURL string
|
||||||
|
HistoryURL string
|
||||||
|
AnalyticsURL string
|
||||||
|
|
||||||
|
// Analytics snapshot
|
||||||
|
TodayPageviews int64
|
||||||
|
PageviewsTrend string // "up", "down", "flat"
|
||||||
|
TrendPercent int
|
||||||
|
|
||||||
|
// Blog-specific fields
|
||||||
|
ReadingTime int // minutes
|
||||||
|
WordCount int
|
||||||
|
CategoryCount int
|
||||||
|
AuthorSlug string
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusBadgeClass returns the Tailwind classes for the status badge
|
||||||
|
func (t ToolbarData) StatusBadgeClass() string {
|
||||||
|
switch t.Status {
|
||||||
|
case "published":
|
||||||
|
return "bg-success text-success-foreground"
|
||||||
|
case "scheduled":
|
||||||
|
return "bg-info text-info-foreground"
|
||||||
|
default:
|
||||||
|
return "bg-warning text-warning-foreground"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusLabel returns a human-readable status label
|
||||||
|
func (t ToolbarData) StatusLabel() string {
|
||||||
|
switch t.Status {
|
||||||
|
case "published":
|
||||||
|
return "Published"
|
||||||
|
case "scheduled":
|
||||||
|
return "Scheduled"
|
||||||
|
default:
|
||||||
|
return "Draft"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostTypeLabel returns a human-readable post type label
|
||||||
|
func (t ToolbarData) PostTypeLabel() string {
|
||||||
|
switch t.PostType {
|
||||||
|
case "post":
|
||||||
|
return "Post"
|
||||||
|
case "master":
|
||||||
|
return "Master"
|
||||||
|
case "system":
|
||||||
|
return "System"
|
||||||
|
default:
|
||||||
|
return "Page"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsPreviewMode returns true if currently viewing draft/preview
|
||||||
|
func (t ToolbarData) IsPreviewMode() bool {
|
||||||
|
return t.PreviewMode == "draft"
|
||||||
|
}
|
||||||
|
|
||||||
|
// CanPublish returns true if the publish button should be enabled
|
||||||
|
func (t ToolbarData) CanPublish() bool {
|
||||||
|
return t.HasUnpublishedChanges
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastModifiedFormatted returns the last modified time in a readable format
|
||||||
|
func (t ToolbarData) LastModifiedFormatted() string {
|
||||||
|
return t.LastModified.Format("Jan 2, 2006 3:04 PM")
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTopPosition returns true if toolbar should be at top (for legacy compatibility)
|
||||||
|
func (t ToolbarData) IsTopPosition() bool {
|
||||||
|
return t.Position == "tl" || t.Position == "tc" || t.Position == "tr" || t.Position == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBottomPosition returns true if toolbar is at bottom
|
||||||
|
func (t ToolbarData) IsBottomPosition() bool {
|
||||||
|
return t.Position == "bl" || t.Position == "bc" || t.Position == "br"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsLeftPosition returns true if toolbar is on the left side
|
||||||
|
func (t ToolbarData) IsLeftPosition() bool {
|
||||||
|
return t.Position == "tl" || t.Position == "bl"
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRightPosition returns true if toolbar is on the right side
|
||||||
|
func (t ToolbarData) IsRightPosition() bool {
|
||||||
|
return t.Position == "tr" || t.Position == "br" || t.Position == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCenterPosition returns true if toolbar is centered
|
||||||
|
func (t ToolbarData) IsCenterPosition() bool {
|
||||||
|
return t.Position == "tc" || t.Position == "bc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropdownDirection returns "up" or "down" based on toolbar vertical position
|
||||||
|
func (t ToolbarData) DropdownDirection() string {
|
||||||
|
if t.IsBottomPosition() {
|
||||||
|
return "up"
|
||||||
|
}
|
||||||
|
return "down"
|
||||||
|
}
|
||||||
|
|
||||||
|
// DropdownAlign returns "left", "center", or "right" based on toolbar horizontal position
|
||||||
|
func (t ToolbarData) DropdownAlign() string {
|
||||||
|
if t.IsLeftPosition() {
|
||||||
|
return "left"
|
||||||
|
} else if t.IsCenterPosition() {
|
||||||
|
return "center"
|
||||||
|
}
|
||||||
|
return "right"
|
||||||
|
}
|
||||||
|
|
||||||
|
// PositionClasses returns the positioning classes for the floating pill
|
||||||
|
func (t ToolbarData) PositionClasses() string {
|
||||||
|
switch t.Position {
|
||||||
|
case "tl":
|
||||||
|
return "top-4 left-4"
|
||||||
|
case "tc":
|
||||||
|
return "top-4 left-1/2 -translate-x-1/2"
|
||||||
|
case "bl":
|
||||||
|
return "bottom-4 left-4"
|
||||||
|
case "bc":
|
||||||
|
return "bottom-4 left-1/2 -translate-x-1/2"
|
||||||
|
case "br":
|
||||||
|
return "bottom-4 right-4"
|
||||||
|
default: // "tr" or empty
|
||||||
|
return "top-4 right-4"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsBlogPost returns true if this is a blog post
|
||||||
|
func (t ToolbarData) IsBlogPost() bool {
|
||||||
|
return t.PostType == "post"
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendIcon returns the trend icon for pageviews
|
||||||
|
func (t ToolbarData) TrendIcon() string {
|
||||||
|
switch t.PageviewsTrend {
|
||||||
|
case "up":
|
||||||
|
return "↑"
|
||||||
|
case "down":
|
||||||
|
return "↓"
|
||||||
|
default:
|
||||||
|
return "→"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendColorClass returns the color class for the trend indicator
|
||||||
|
func (t ToolbarData) TrendColorClass() string {
|
||||||
|
switch t.PageviewsTrend {
|
||||||
|
case "up":
|
||||||
|
return "text-success"
|
||||||
|
case "down":
|
||||||
|
return "text-destructive"
|
||||||
|
default:
|
||||||
|
return "text-muted-foreground"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminEditorToolbar renders the floating pill toolbar for admins on public pages
|
||||||
|
// Uses inverted color scheme - dark on light backgrounds, light on dark backgrounds
|
||||||
|
func AdminEditorToolbar(data ToolbarData) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var1 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var1 == nil {
|
||||||
|
templ_7745c5c3_Var1 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
if data.Enabled {
|
||||||
|
var templ_7745c5c3_Var2 = []any{"bn-toolbar fixed z-[9999] flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-full backdrop-blur-md transition-all duration-300", data.PositionClasses()}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var2...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<div id=\"bn-admin-toolbar\" class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var3 string
|
||||||
|
templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var2).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 2, "\"><style>\n\t\t\t\t/* Toolbar: dark by default (for light pages), light when page has .dark class */\n\t\t\t\t.bn-toolbar {\n\t\t\t\t\t--bn-toolbar-bg: rgba(24, 24, 27, 0.95);\n\t\t\t\t\t--bn-toolbar-fg: #fafafa;\n\t\t\t\t\t--bn-toolbar-muted: #a1a1aa;\n\t\t\t\t\t--bn-toolbar-border: rgba(63, 63, 70, 0.8);\n\t\t\t\t\t--bn-toolbar-hover: rgba(63, 63, 70, 0.5);\n\t\t\t\t\t--bn-toolbar-primary-bg: #3b82f6;\n\t\t\t\t\t--bn-toolbar-primary-fg: #ffffff;\n\t\t\t\t\tbackground: var(--bn-toolbar-bg);\n\t\t\t\t\tcolor: var(--bn-toolbar-fg);\n\t\t\t\t\tborder: 1px solid var(--bn-toolbar-border);\n\t\t\t\t\tbox-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.2);\n\t\t\t\t}\n\t\t\t\t/* Light toolbar when page theme is dark (.dark class on html or body) */\n\t\t\t\t.dark .bn-toolbar, html.dark .bn-toolbar, body.dark .bn-toolbar {\n\t\t\t\t\t--bn-toolbar-bg: rgba(250, 250, 250, 0.95);\n\t\t\t\t\t--bn-toolbar-fg: #18181b;\n\t\t\t\t\t--bn-toolbar-muted: #71717a;\n\t\t\t\t\t--bn-toolbar-border: rgba(228, 228, 231, 0.8);\n\t\t\t\t\t--bn-toolbar-hover: rgba(228, 228, 231, 0.5);\n\t\t\t\t\tbox-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.3), 0 8px 10px -6px rgba(0, 0, 0, 0.25);\n\t\t\t\t}\n\t\t\t\t.bn-toolbar .bn-toolbar-text { color: var(--bn-toolbar-fg); }\n\t\t\t\t.bn-toolbar .bn-toolbar-muted { color: var(--bn-toolbar-muted); }\n\t\t\t\t.bn-toolbar .bn-toolbar-divider { background: var(--bn-toolbar-border); }\n\t\t\t\t.bn-toolbar .bn-toolbar-hover:hover { background: var(--bn-toolbar-hover); }\n\t\t\t\t.bn-toolbar .bn-toolbar-primary {\n\t\t\t\t\tbackground: var(--bn-toolbar-primary-bg);\n\t\t\t\t\tcolor: var(--bn-toolbar-primary-fg);\n\t\t\t\t}\n\t\t\t\t.bn-toolbar .bn-toolbar-primary:hover {\n\t\t\t\t\tbackground: color-mix(in srgb, var(--bn-toolbar-primary-bg) 90%, black);\n\t\t\t\t}\n\t\t\t\t/* Dropdown styling - inherits toolbar vars */\n\t\t\t\t.bn-toolbar-dropdown {\n\t\t\t\t\tbackground: var(--bn-toolbar-bg);\n\t\t\t\t\tcolor: var(--bn-toolbar-fg);\n\t\t\t\t\tborder: 1px solid var(--bn-toolbar-border);\n\t\t\t\t\tbox-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.2);\n\t\t\t\t}\n\t\t\t\t.bn-toolbar-dropdown .bn-dd-border { border-color: var(--bn-toolbar-border); }\n\t\t\t\t.bn-toolbar-dropdown .bn-dd-text { color: var(--bn-toolbar-fg); }\n\t\t\t\t.bn-toolbar-dropdown .bn-dd-muted { color: var(--bn-toolbar-muted); }\n\t\t\t\t.bn-toolbar-dropdown .bn-dd-hover:hover { background: var(--bn-toolbar-hover); }\n\t\t\t\t.bn-toolbar-dropdown .bn-dd-link { color: var(--bn-toolbar-primary-bg); }\n\t\t\t\t.bn-toolbar-dropdown .bn-dd-link:hover { text-decoration: underline; }\n\t\t\t\t/* Dropdown positioning based on data attributes */\n\t\t\t\t.bn-dropdown-container { position: absolute; }\n\t\t\t\t.bn-dropdown-container[data-direction=\"up\"] { bottom: 100%; margin-bottom: 0.5rem; }\n\t\t\t\t.bn-dropdown-container[data-direction=\"down\"] { top: 100%; margin-top: 0.5rem; }\n\t\t\t\t.bn-dropdown-container[data-align=\"left\"] { left: 0; }\n\t\t\t\t.bn-dropdown-container[data-align=\"right\"] { right: 0; }\n\t\t\t\t.bn-dropdown-container[data-align=\"center\"] { left: 50%; transform: translateX(-50%); }\n\t\t\t</style><!-- Edit button --><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var4 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.EditURL))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 251, Col: 38}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 3, "\" class=\"bn-toolbar-primary inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full transition-colors\" title=\"Edit this page\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path d=\"M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z\"></path></svg> <span class=\"hidden sm:inline\">Edit</span></a><!-- Status indicator --><div class=\"flex items-center gap-1.5 px-2\" title=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var5 string
|
||||||
|
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(data.StatusLabel())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 261, Col: 73}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 4, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var6 = []any{"w-2 h-2 rounded-full", templ.KV("bg-success", data.Status == "published"), templ.KV("bg-warning", data.Status == "draft"), templ.KV("bg-info", data.Status == "scheduled"), templ.KV("animate-pulse ring-2 ring-warning/50", data.HasUnpublishedChanges)}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var6...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 5, "<span class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var7 string
|
||||||
|
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var6).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"></span> <span class=\"bn-toolbar-muted text-xs hidden sm:inline\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var8 string
|
||||||
|
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.StatusLabel())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 263, Col: 80}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span></div><!-- Divider --><div class=\"bn-toolbar-divider w-px h-5\"></div><!-- Preview toggle - compact -->")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var9 = []any{"bn-toolbar-hover inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors", templ.KV("!bg-info/20 text-info", data.IsPreviewMode()), templ.KV("bn-toolbar-muted", !data.IsPreviewMode())}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var9...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, togglePreviewMode(data.IsPreviewMode()))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<button type=\"button\" class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var10 string
|
||||||
|
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var9).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" onclick=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var11 templ.ComponentScript = togglePreviewMode(data.IsPreviewMode())
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11.Call)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "\" title=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var12 string
|
||||||
|
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(func() string {
|
||||||
|
if data.IsPreviewMode() {
|
||||||
|
return "Viewing draft - click to view published"
|
||||||
|
} else {
|
||||||
|
return "Viewing published - click to preview draft"
|
||||||
|
}
|
||||||
|
}())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 272, Col: 169}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.IsPreviewMode() {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-3.5 w-3.5\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path d=\"M10 12a2 2 0 100-4 2 2 0 000 4z\"></path> <path fill-rule=\"evenodd\" d=\"M.458 10C1.732 5.943 5.522 3 10 3s8.268 2.943 9.542 7c-1.274 4.057-5.064 7-9.542 7S1.732 14.057.458 10zM14 10a4 4 0 11-8 0 4 4 0 018 0z\" clip-rule=\"evenodd\"></path></svg> <span class=\"hidden sm:inline\">Draft</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-3.5 w-3.5\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z\" clip-rule=\"evenodd\"></path></svg> <span class=\"hidden sm:inline\">Live</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</button><!-- Blog-specific: Reading time -->")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.IsBlogPost() && data.ReadingTime > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"bn-toolbar-muted hidden md:flex items-center gap-1 px-2 text-xs\" title=\"Reading time\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-3.5 w-3.5\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z\" clip-rule=\"evenodd\"></path></svg> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var13 string
|
||||||
|
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d min", data.ReadingTime))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 293, Col: 46}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<!-- Publish button (if changes) -->")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.CanPublish() {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<button type=\"button\" class=\"inline-flex items-center gap-1.5 px-3 py-1.5 bg-success hover:bg-success/90 text-success-foreground rounded-full transition-colors\" hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var14 string
|
||||||
|
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/toolbar/publish/%s", data.PageID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 301, Col: 62}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" hx-target=\"#bn-admin-toolbar\" hx-swap=\"outerHTML\" hx-confirm=\"Publish this page?\" title=\"Publish changes\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z\" clip-rule=\"evenodd\"></path></svg> <span class=\"hidden sm:inline\">Publish</span></button>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<!-- More menu --><div class=\"relative\" id=\"more-menu-container\"><button type=\"button\" class=\"bn-toolbar-hover bn-toolbar-muted inline-flex items-center justify-center w-8 h-8 rounded-full transition-colors\" hx-get=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var15 string
|
||||||
|
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/toolbar/page-info/%s", data.PageID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 318, Col: 63}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" hx-target=\"#page-info-dropdown\" hx-trigger=\"click\" hx-swap=\"innerHTML\" title=\"More options\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-5 w-5\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path d=\"M6 10a2 2 0 11-4 0 2 2 0 014 0zM12 10a2 2 0 11-4 0 2 2 0 014 0zM16 12a2 2 0 100-4 2 2 0 000 4z\"></path></svg></button><div id=\"page-info-dropdown\" class=\"bn-dropdown-container\" data-direction=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var16 string
|
||||||
|
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(data.DropdownDirection())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 331, Col: 46}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" data-align=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var17 string
|
||||||
|
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(data.DropdownAlign())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 332, Col: 38}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\"></div></div></div><!-- Click outside to close dropdowns --> <script>\n\t\t\tdocument.addEventListener('click', function(e) {\n\t\t\t\tif (!e.target.closest('#more-menu-container')) {\n\t\t\t\t\tdocument.getElementById('page-info-dropdown').innerHTML = '';\n\t\t\t\t}\n\t\t\t});\n\t\t</script>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageInfoDropdown renders the page info and quick actions dropdown
|
||||||
|
func PageInfoDropdown(data ToolbarData) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var18 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var18 == nil {
|
||||||
|
templ_7745c5c3_Var18 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "<div class=\"bn-toolbar-dropdown rounded-lg py-2 min-w-[260px] max-w-[320px]\"><!-- Analytics snapshot -->")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.TodayPageviews > 0 || data.PageviewsTrend != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"bn-dd-border px-3 py-2 border-b\"><div class=\"flex items-center justify-between\"><div class=\"flex items-center gap-2\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"bn-dd-muted h-4 w-4\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path d=\"M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z\"></path></svg> <span class=\"bn-dd-text text-sm font-medium\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var19 string
|
||||||
|
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", data.TodayPageviews))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 358, Col: 91}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, " views today</span></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.TrendPercent != 0 {
|
||||||
|
var templ_7745c5c3_Var20 = []any{"text-xs font-medium", data.TrendColorClass()}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<span class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var21 string
|
||||||
|
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var20).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var21))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var22 string
|
||||||
|
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.TrendIcon())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 362, Col: 25}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, " ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var23 string
|
||||||
|
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d%%", abs(data.TrendPercent)))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 362, Col: 73}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</div></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<!-- Blog-specific: Author & Reading time -->")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.IsBlogPost() {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<div class=\"bn-dd-border px-3 py-2 border-b\"><div class=\"flex items-center justify-between text-sm\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.AuthorName != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div class=\"flex items-center gap-2\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"bn-dd-muted h-4 w-4\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path fill-rule=\"evenodd\" d=\"M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z\" clip-rule=\"evenodd\"></path></svg> ")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.AuthorSlug != "" {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "<a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var24 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL("/author/" + data.AuthorSlug))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 378, Col: 61}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" class=\"bn-dd-link\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var25 string
|
||||||
|
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(data.AuthorName)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 378, Col: 100}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</a>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<span class=\"bn-dd-text\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var26 string
|
||||||
|
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(data.AuthorName)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 380, Col: 50}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data.ReadingTime > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<span class=\"bn-dd-muted text-xs\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var27 string
|
||||||
|
templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d min read", data.ReadingTime))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 385, Col: 86}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</span>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.WordCount > 0 {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "<div class=\"bn-dd-muted text-xs mt-1\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var28 string
|
||||||
|
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d words", data.WordCount))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 389, Col: 84}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<!-- Page info --><div class=\"bn-dd-border bn-dd-muted px-3 py-2 border-b text-xs\"><div class=\"flex justify-between\"><span>Template</span> <span class=\"bn-dd-text\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var29 string
|
||||||
|
templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.JoinStringErrs(data.TemplateName)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 397, Col: 48}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var29))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "</span></div><div class=\"flex justify-between mt-1\"><span>Modified</span> <span class=\"bn-dd-text\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var30 string
|
||||||
|
templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.JoinStringErrs(data.LastModifiedFormatted())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 401, Col: 59}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var30))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</span></div></div><!-- Quick actions --><div class=\"py-1\"><a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var31 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.SettingsURL))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 406, Col: 44}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var31))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" class=\"bn-dd-hover bn-dd-text flex items-center gap-2 px-3 py-1.5 text-sm transition-colors\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"bn-dd-muted h-4 w-4\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path fill-rule=\"evenodd\" d=\"M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z\" clip-rule=\"evenodd\"></path></svg> Settings</a> <a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var32 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.HistoryURL))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 412, Col: 43}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var32))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "\" class=\"bn-dd-hover bn-dd-text flex items-center gap-2 px-3 py-1.5 text-sm transition-colors\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"bn-dd-muted h-4 w-4\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path fill-rule=\"evenodd\" d=\"M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z\" clip-rule=\"evenodd\"></path></svg> History</a> <a href=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var33 templ.SafeURL
|
||||||
|
templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.JoinURLErrs(templ.SafeURL(data.AnalyticsURL))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 418, Col: 45}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var33))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" class=\"bn-dd-hover bn-dd-text flex items-center gap-2 px-3 py-1.5 text-sm transition-colors\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"bn-dd-muted h-4 w-4\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path d=\"M2 11a1 1 0 011-1h2a1 1 0 011 1v5a1 1 0 01-1 1H3a1 1 0 01-1-1v-5zM8 7a1 1 0 011-1h2a1 1 0 011 1v9a1 1 0 01-1 1H9a1 1 0 01-1-1V7zM14 4a1 1 0 011-1h2a1 1 0 011 1v12a1 1 0 01-1 1h-2a1 1 0 01-1-1V4z\"></path></svg> Analytics</a></div><!-- Sharing --><div class=\"bn-dd-border border-t py-1\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templ.RenderScriptItems(ctx, templ_7745c5c3_Buffer, copyPageURL(data.PageSlug))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "<button type=\"button\" class=\"bn-dd-hover bn-dd-text w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors\" onclick=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var34 templ.ComponentScript = copyPageURL(data.PageSlug)
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34.Call)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"bn-dd-muted h-4 w-4\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path d=\"M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z\"></path></svg> Copy link</button> <button type=\"button\" class=\"bn-dd-hover bn-dd-text w-full flex items-center gap-2 px-3 py-1.5 text-sm transition-colors\" hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var35 string
|
||||||
|
templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/toolbar/share-preview/%s", data.PageID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 440, Col: 67}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var35))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "\" hx-swap=\"innerHTML\" hx-target=\"this\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"bn-dd-muted h-4 w-4\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path d=\"M15 8a3 3 0 10-2.977-2.63l-4.94 2.47a3 3 0 100 4.319l4.94 2.47a3 3 0 10.895-1.789l-4.94-2.47a3.027 3.027 0 000-.74l4.94-2.47C13.456 7.68 14.19 8 15 8z\"></path></svg> Share preview link</button></div><!-- Position selector --><div class=\"bn-dd-border border-t px-3 py-2\"><div class=\"bn-dd-muted text-xs mb-2\">Move toolbar</div><div class=\"grid grid-cols-3 gap-1\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = positionButton(data.PageID, "tl", data.Position, "Top left").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = positionButton(data.PageID, "tc", data.Position, "Top center").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = positionButton(data.PageID, "tr", data.Position, "Top right").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = positionButton(data.PageID, "bl", data.Position, "Bottom left").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = positionButton(data.PageID, "bc", data.Position, "Bottom center").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = positionButton(data.PageID, "br", data.Position, "Bottom right").Render(ctx, templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, "</div></div><!-- Danger zone -->")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
if data.HasUnpublishedChanges {
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<div class=\"bn-dd-border border-t py-1\"><button type=\"button\" class=\"w-full flex items-center gap-2 px-3 py-1.5 text-sm text-destructive hover:bg-destructive/10 transition-colors\" hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var36 string
|
||||||
|
templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/toolbar/discard/%s", data.PageID))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 468, Col: 62}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var36))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\" hx-target=\"#bn-admin-toolbar\" hx-swap=\"outerHTML\" hx-confirm=\"Discard all unpublished changes? This cannot be undone.\"><svg xmlns=\"http://www.w3.org/2000/svg\" class=\"h-4 w-4\" viewBox=\"0 0 20 20\" fill=\"currentColor\"><path fill-rule=\"evenodd\" d=\"M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z\" clip-rule=\"evenodd\"></path></svg> Discard changes</button></div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</div>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// positionButton renders a position selector button
|
||||||
|
func positionButton(pageID uuid.UUID, pos string, currentPos string, label string) templ.Component {
|
||||||
|
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||||
|
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||||
|
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||||
|
return templ_7745c5c3_CtxErr
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Buffer, templ_7745c5c3_IsBuffer := templruntime.GetBuffer(templ_7745c5c3_W)
|
||||||
|
if !templ_7745c5c3_IsBuffer {
|
||||||
|
defer func() {
|
||||||
|
templ_7745c5c3_BufErr := templruntime.ReleaseBuffer(templ_7745c5c3_Buffer)
|
||||||
|
if templ_7745c5c3_Err == nil {
|
||||||
|
templ_7745c5c3_Err = templ_7745c5c3_BufErr
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
ctx = templ.InitializeContext(ctx)
|
||||||
|
templ_7745c5c3_Var37 := templ.GetChildren(ctx)
|
||||||
|
if templ_7745c5c3_Var37 == nil {
|
||||||
|
templ_7745c5c3_Var37 = templ.NopComponent
|
||||||
|
}
|
||||||
|
ctx = templ.ClearChildren(ctx)
|
||||||
|
var templ_7745c5c3_Var38 = []any{"w-8 h-6 rounded border transition-colors flex items-center justify-center", templ.KV("bg-info border-info", pos == currentPos || (currentPos == "" && pos == "tr")), templ.KV("bn-dd-border bn-dd-hover", pos != currentPos && !(currentPos == "" && pos == "tr"))}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var38...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "<button type=\"button\" class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var39 string
|
||||||
|
templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var38).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var39))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\" hx-post=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var40 string
|
||||||
|
templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("/toolbar/set-position/%s?pos=%s", pageID, pos))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 488, Col: 71}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var40))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, "\" hx-target=\"#bn-admin-toolbar\" hx-swap=\"outerHTML\" title=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var41 string
|
||||||
|
templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.JoinStringErrs(label)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 491, Col: 15}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var41))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, "\">")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var42 = []any{"w-1.5 h-1.5 rounded-full", templ.KV("bg-info-foreground", pos == currentPos || (currentPos == "" && pos == "tr")), templ.KV("bn-dd-muted", pos != currentPos && !(currentPos == "" && pos == "tr"))}
|
||||||
|
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var42...)
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "<span class=\"")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
var templ_7745c5c3_Var43 string
|
||||||
|
templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.JoinStringErrs(templ.CSSClasses(templ_7745c5c3_Var42).String())
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ.Error{Err: templ_7745c5c3_Err, FileName: `toolbar.templ`, Line: 1, Col: 0}
|
||||||
|
}
|
||||||
|
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var43))
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, "\"></span></button>")
|
||||||
|
if templ_7745c5c3_Err != nil {
|
||||||
|
return templ_7745c5c3_Err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// abs returns the absolute value of an integer
|
||||||
|
func abs(n int) int {
|
||||||
|
if n < 0 {
|
||||||
|
return -n
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyPageURL generates the script to copy page URL
|
||||||
|
func copyPageURL(slug string) templ.ComponentScript {
|
||||||
|
return templ.ComponentScript{
|
||||||
|
Name: `__templ_copyPageURL_044c`,
|
||||||
|
Function: `function __templ_copyPageURL_044c(slug){const url = window.location.origin + slug;
|
||||||
|
navigator.clipboard.writeText(url).then(() => {
|
||||||
|
// Show brief feedback - could enhance with toast later
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg> Copied!';
|
||||||
|
setTimeout(() => { btn.innerHTML = originalText; }, 2000);
|
||||||
|
});
|
||||||
|
}`,
|
||||||
|
Call: templ.SafeScript(`__templ_copyPageURL_044c`, slug),
|
||||||
|
CallInline: templ.SafeScriptInline(`__templ_copyPageURL_044c`, slug),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// togglePreviewMode toggles between preview (draft) and published mode via URL
|
||||||
|
func togglePreviewMode(isCurrentlyPreview bool) templ.ComponentScript {
|
||||||
|
return templ.ComponentScript{
|
||||||
|
Name: `__templ_togglePreviewMode_114f`,
|
||||||
|
Function: `function __templ_togglePreviewMode_114f(isCurrentlyPreview){const url = new URL(window.location.href);
|
||||||
|
if (isCurrentlyPreview) {
|
||||||
|
// Currently previewing, remove preview param
|
||||||
|
url.searchParams.delete('preview');
|
||||||
|
} else {
|
||||||
|
// Not previewing, add preview param
|
||||||
|
url.searchParams.set('preview', '1');
|
||||||
|
}
|
||||||
|
window.location.href = url.toString();
|
||||||
|
}`,
|
||||||
|
Call: templ.SafeScript(`__templ_togglePreviewMode_114f`, isCurrentlyPreview),
|
||||||
|
CallInline: templ.SafeScriptInline(`__templ_togglePreviewMode_114f`, isCurrentlyPreview),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ = templruntime.GeneratedTemplate
|
||||||
50
templates/bn/validation.go
Normal file
50
templates/bn/validation.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package bn
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
)
|
||||||
|
|
||||||
|
// validationContextKey is the key used to store validation tracking in context.
|
||||||
|
type validationContextKey struct{}
|
||||||
|
|
||||||
|
// ValidationTracker tracks which bn functions are called during template rendering.
|
||||||
|
// Used during template registration to validate templates include required calls.
|
||||||
|
type ValidationTracker struct {
|
||||||
|
HeadCalled bool
|
||||||
|
BodyEndCalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidationContext creates a context with validation tracking enabled.
|
||||||
|
// Use GetValidationTracker after rendering to check which functions were called.
|
||||||
|
func NewValidationContext(ctx context.Context) (context.Context, *ValidationTracker) {
|
||||||
|
tracker := &ValidationTracker{}
|
||||||
|
return context.WithValue(ctx, validationContextKey{}, tracker), tracker
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValidationTracker retrieves the validation tracker from context, if any.
|
||||||
|
func GetValidationTracker(ctx context.Context) *ValidationTracker {
|
||||||
|
if tracker, ok := ctx.Value(validationContextKey{}).(*ValidationTracker); ok {
|
||||||
|
return tracker
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordValidationCall records that a bn function was called during rendering.
|
||||||
|
// Returns an empty component (renders nothing) but has the side effect of tracking the call.
|
||||||
|
func recordValidationCall(ctx context.Context, fnName string) templ.Component {
|
||||||
|
if tracker := GetValidationTracker(ctx); tracker != nil {
|
||||||
|
switch fnName {
|
||||||
|
case "Head":
|
||||||
|
tracker.HeadCalled = true
|
||||||
|
case "BodyEnd":
|
||||||
|
tracker.BodyEndCalled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Return empty component - renders nothing
|
||||||
|
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) error {
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user