core/templates/bn/toolbar.templ
Alex Dunmow 868df2d761 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>
2026-05-01 09:17:55 +08:00

529 lines
22 KiB
Plaintext

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();
}