Head, engagement, toolbar templ components and validation helpers for use by template plugins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
529 lines
22 KiB
Plaintext
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();
|
|
}
|