Bootstrap the plugin-facing SDK module so the fleet migration (P2) becomes a mechanical block/core/X -> block/pluginsdk/X import rewrite. - abi/proto/v1: the single source-of-truth ABI proto tree, copied from the cms authoring source (cms/backend/abi/proto/v1) with go_package retargeted to git.dev.alexdunmow.com/block/pluginsdk/abi/v1;abiv1. Kills the hand-kept cms/core proto duplication (audit gap 4). - abi/v1: generated Go bindings (buf generate); byte-identical to core's abi/v1 save the embedded go_package path. - plugin/ (registration + DI surface), plugin/wasmguest/** (transport shim, caps stubs, bnwasm db driver, testdata fixtures + golden .pb), and the guest-facing type packages: blocks (+builtin/shared/tags), templates (+pongo/bn), auth, settings, content, gating, crypto, rbac, video, ai, subscriptions, menus, datasources. Internal imports rewritten core -> pluginsdk; zero block/core references remain. - README/AGENTS(+CLAUDE symlink)/Makefile: proto is the contract, Go is one binding; no replace directives; templates/bn is a synced copy authored in cms. Spec: cms docs/superpowers/specs/2026-07-07-proto-first-plugin-sdk-design.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
687 lines
29 KiB
Plaintext
687 lines
29 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"
|
|
HidePreviewToggle bool
|
|
Animate bool // true only on initial full-page render (entrance animation); false on HTMX re-renders
|
|
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"
|
|
}
|
|
|
|
// EnterAnimationClass returns the entrance-animation classes for the initial
|
|
// full-page render. It slides the pill in from the edge it rests against (up for
|
|
// bottom positions, down for top) then gives a brief pulse. Empty on HTMX
|
|
// re-renders (Animate=false) so publish/discard/reposition swaps appear instantly.
|
|
func (t ToolbarData) EnterAnimationClass() string {
|
|
if !t.Animate {
|
|
return ""
|
|
}
|
|
if t.IsBottomPosition() {
|
|
return "bn-toolbar-enter bn-toolbar-enter-up"
|
|
}
|
|
return "bn-toolbar-enter bn-toolbar-enter-down"
|
|
}
|
|
|
|
// 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(), data.EnterAnimationClass() }
|
|
>
|
|
<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);
|
|
}
|
|
/* Entrance: slide in from the resting edge, then a brief pulse.
|
|
Animates `transform` only; Tailwind v4 centres via the separate
|
|
`translate` property, so `-translate-x-1/2` is preserved. Runs
|
|
~1s after render, 500ms total, initial page load only. */
|
|
@keyframes bn-toolbar-slide-up {
|
|
0% { opacity: 0; transform: translateY(1.25rem); }
|
|
60% { opacity: 1; transform: translateY(0) scale(1); }
|
|
80% { transform: translateY(0) scale(1.05); }
|
|
100% { opacity: 1; transform: translateY(0) scale(1); }
|
|
}
|
|
@keyframes bn-toolbar-slide-down {
|
|
0% { opacity: 0; transform: translateY(-1.25rem); }
|
|
60% { opacity: 1; transform: translateY(0) scale(1); }
|
|
80% { transform: translateY(0) scale(1.05); }
|
|
100% { opacity: 1; transform: translateY(0) scale(1); }
|
|
}
|
|
.bn-toolbar-enter {
|
|
animation-duration: 500ms;
|
|
animation-delay: 900ms;
|
|
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
|
animation-fill-mode: both;
|
|
}
|
|
.bn-toolbar-enter-up { animation-name: bn-toolbar-slide-up; }
|
|
.bn-toolbar-enter-down { animation-name: bn-toolbar-slide-down; }
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.bn-toolbar-enter { animation: none; }
|
|
}
|
|
/* 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);
|
|
}
|
|
/* Theme tester: highlighted while a light/dark override is forced.
|
|
Uses toolbar vars (not site theme tokens) so it renders on any site. */
|
|
.bn-toolbar .bn-theme-tester-active,
|
|
.bn-toolbar .bn-theme-tester-active:hover {
|
|
background: color-mix(in srgb, var(--bn-toolbar-primary-bg) 20%, transparent);
|
|
color: var(--bn-toolbar-primary-bg);
|
|
}
|
|
/* 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>
|
|
if !data.HidePreviewToggle {
|
|
<!-- 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>
|
|
}
|
|
<!-- Divider -->
|
|
<div class="bn-toolbar-divider w-px h-5"></div>
|
|
<!-- Theme tester: cycles site default → forced light → forced dark.
|
|
Per-tab override (sessionStorage), applied by bnApplyTheme in head.templ.
|
|
Never touches the visitor bn-theme preference. -->
|
|
<button
|
|
type="button"
|
|
id="bn-theme-tester"
|
|
class="bn-toolbar-hover bn-toolbar-muted inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs transition-colors"
|
|
onclick="bnCycleThemeTester()"
|
|
title="Theme: site default — click to force light"
|
|
>
|
|
<span data-tt="auto">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"></rect>
|
|
<line x1="8" y1="21" x2="16" y2="21"></line>
|
|
<line x1="12" y1="17" x2="12" y2="21"></line>
|
|
</svg>
|
|
</span>
|
|
<span data-tt="light" style="display:none">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="5"></circle>
|
|
<line x1="12" y1="1" x2="12" y2="3"></line>
|
|
<line x1="12" y1="21" x2="12" y2="23"></line>
|
|
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line>
|
|
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line>
|
|
<line x1="1" y1="12" x2="3" y2="12"></line>
|
|
<line x1="21" y1="12" x2="23" y2="12"></line>
|
|
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line>
|
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line>
|
|
</svg>
|
|
</span>
|
|
<span data-tt="dark" style="display:none">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
|
</svg>
|
|
</span>
|
|
<span data-tt-label class="hidden sm:inline" style="display:none"></span>
|
|
</button>
|
|
<script>
|
|
// Theme tester: per-tab light/dark override for admins. Lives inside
|
|
// #bn-admin-toolbar so HTMX outerHTML swaps re-run the sync call.
|
|
window.bnCycleThemeTester = function() {
|
|
var cur = null;
|
|
try { cur = sessionStorage.getItem('bn-theme-override'); } catch (e) {}
|
|
var next = cur === 'light' ? 'dark' : (cur === 'dark' ? null : 'light');
|
|
try {
|
|
if (next) sessionStorage.setItem('bn-theme-override', next);
|
|
else sessionStorage.removeItem('bn-theme-override');
|
|
} catch (e) {}
|
|
if (window.bnApplyTheme) {
|
|
window.bnApplyTheme();
|
|
} else if (next) {
|
|
// Fallback for templates without the bn head script
|
|
document.documentElement.classList.toggle('dark', next === 'dark');
|
|
}
|
|
window.bnSyncThemeTester();
|
|
// Let darkmode-switcher blocks refresh their icons
|
|
var mode = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
|
document.querySelectorAll('[data-bn-theme-toggle]').forEach(function(el) {
|
|
el.dispatchEvent(new CustomEvent('bn:theme-changed', { detail: { mode: mode } }));
|
|
});
|
|
};
|
|
window.bnSyncThemeTester = function() {
|
|
var btn = document.getElementById('bn-theme-tester');
|
|
if (!btn) return;
|
|
var cur = null;
|
|
try { cur = sessionStorage.getItem('bn-theme-override'); } catch (e) {}
|
|
var state = (cur === 'light' || cur === 'dark') ? cur : 'auto';
|
|
btn.querySelectorAll('[data-tt]').forEach(function(s) {
|
|
s.style.display = s.getAttribute('data-tt') === state ? '' : 'none';
|
|
});
|
|
var lbl = btn.querySelector('[data-tt-label]');
|
|
if (state === 'auto') {
|
|
lbl.style.display = 'none';
|
|
lbl.textContent = '';
|
|
} else {
|
|
lbl.style.display = '';
|
|
lbl.textContent = state === 'light' ? 'Light' : 'Dark';
|
|
}
|
|
btn.classList.toggle('bn-theme-tester-active', state !== 'auto');
|
|
btn.classList.toggle('bn-toolbar-muted', state === 'auto');
|
|
btn.title = state === 'auto'
|
|
? 'Theme: site default — click to force light'
|
|
: (state === 'light'
|
|
? 'Theme: forced light — click to force dark'
|
|
: 'Theme: forced dark — click to reset to site default');
|
|
};
|
|
window.bnSyncThemeTester();
|
|
</script>
|
|
<!-- 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]">
|
|
<style>
|
|
/* Position-picker chips - shipped with the fragment so styling never
|
|
desyncs from the separately-rendered toolbar shell. Uses toolbar
|
|
vars (resolve via the enclosing .bn-toolbar) to stay visible in
|
|
both inverted themes. */
|
|
.bn-toolbar-dropdown .bn-dd-poschip { border-color: var(--bn-toolbar-border); }
|
|
.bn-toolbar-dropdown .bn-dd-poschip:hover { background: var(--bn-toolbar-hover); }
|
|
.bn-toolbar-dropdown .bn-dd-poschip-active,
|
|
.bn-toolbar-dropdown .bn-dd-poschip-active:hover {
|
|
background: var(--bn-toolbar-primary-bg);
|
|
border-color: var(--bn-toolbar-primary-bg);
|
|
}
|
|
.bn-toolbar-dropdown .bn-dd-dot { background: var(--bn-toolbar-muted); }
|
|
.bn-toolbar-dropdown .bn-dd-dot-active { background: var(--bn-toolbar-primary-fg); }
|
|
</style>
|
|
<!-- 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) {
|
|
{{ active := pos == currentPos || (currentPos == "" && pos == "tr") }}
|
|
<button
|
|
type="button"
|
|
class={ "bn-dd-poschip w-8 h-6 rounded border transition-colors flex items-center justify-center", templ.KV("bn-dd-poschip-active", active) }
|
|
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("bn-dd-dot-active", active), templ.KV("bn-dd-dot", !active) }></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();
|
|
}
|