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:
Alex Dunmow 2026-05-01 09:17:55 +08:00
parent 71d3416304
commit 868df2d761
8 changed files with 4526 additions and 1 deletions

View File

@ -1 +0,0 @@
package bn

View 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>
}
}

File diff suppressed because one or more lines are too long

915
templates/bn/head.templ Normal file
View 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

File diff suppressed because one or more lines are too long

528
templates/bn/toolbar.templ Normal file
View 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();
}

View 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

View 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
})
}