Robert Birming

Bear Blog dashboard notes

A dashboard plugin that adds a small notes pad to the Bear dashboard, for reminders and ideas you don't want to lose mid-edit.

Opens as a modal from a "Notes" button next to Publish, autosaves as you type, and never leaves your browser. It's styled to match Bear's own dashboard buttons, so it feels like it was always there.1

A heads-up: notes are saved locally in your browser, so they won't sync across devices. Clear your browser data, switch computers, or anger the tech gods, and they're gone. You have been warned.


Preview

Notes modal open over the Bear Blog post editor, showing a text area with autosave status and Copy and Clear buttons

How to use

Installation

  1. Copy the script below.
  2. Go to Customise dashboard.
  3. Paste it under "Dashboard footer content".
  4. Save. Enjoy.

Using it

  1. Click Notes next to Publish and Save as draft, or press Ctrl/Cmd+K from anywhere on the dashboard.
  2. Type away. It autosaves shortly after you stop, and again automatically if you close the modal with unsaved changes.
  3. Use Copy to grab the note's contents, or Clear to empty it (you'll be asked to confirm first).
  4. Press Escape to close, or Ctrl/Cmd+S to save immediately.

There's only one note, so there's nothing to organize or lose track of.

Plugin

<script>
/*
 Plugin name: Dashboard notes
 Description: Adds a small floating notes pad to the Bear dashboard for quick
              reminders and ideas. Notes autosave to localStorage and never
              leave the browser.
 Author: Robert Birming
 Author URI: https://robertbirming.com
*/
(() => {
  "use strict";

  if (window.__bbNotesSingleLoaded) return;
  window.__bbNotesSingleLoaded = true;

  const STORAGE_KEY = "dashboard_note_single_v1";
  const AUTOSAVE_DELAY = 800;
  const STATUS_RESET_DELAY = 900;

  const IDS = {
    style: "bb-notes-modal-style",
    overlay: "bb-notes-overlay",
    title: "bb-notes-title",
    status: "bb-notes-status"
  };

  let autosaveTimer = null;
  let statusResetTimer = null;
  let lastFocusEl = null;
  let openSnapshot = "";

  window.bbNotes = window.bbNotes || {};
  const api = window.bbNotes;

  const qs = (sel, root) => (root || document).querySelector(sel);
  const getOverlay = () => document.getElementById(IDS.overlay);
  const isDashboard = () => (window.location.pathname || "").includes("/dashboard/");

  function safeGetRaw() {
    try {
      return localStorage.getItem(STORAGE_KEY) || "";
    } catch (err) {
      console.error("[Dashboard notes] Could not read from localStorage", err);
      return "";
    }
  }

  function safeSetRaw(value) {
    try {
      localStorage.setItem(STORAGE_KEY, value);
      return true;
    } catch (err) {
      console.error("[Dashboard notes] Could not write to localStorage", err);
      return false;
    }
  }

  function getNoteObject() {
    const raw = safeGetRaw();
    if (!raw) return { content: "", modified: null };

    try {
      const parsed = JSON.parse(raw);
      if (parsed && typeof parsed === "object" && "content" in parsed) {
        return {
          content: typeof parsed.content === "string" ? parsed.content : String(parsed.content || ""),
          modified: parsed.modified || null
        };
      }
      return { content: String(raw), modified: null };
    } catch (err) {
      console.error("[Dashboard notes] Stored note was not valid JSON, using raw value", err);
      return { content: String(raw), modified: null };
    }
  }

  function setNoteObject(obj) {
    return safeSetRaw(JSON.stringify(obj));
  }

  function isValidDate(d) {
    return d instanceof Date && !Number.isNaN(d.getTime());
  }

  function formatDate(isoString) {
    if (!isoString) return "";
    const date = new Date(isoString);
    if (!isValidDate(date)) return "";

    const now = new Date();
    const diff = now - date;

    if (Number.isNaN(diff) || diff < 0) return date.toLocaleDateString();

    const days = Math.floor(diff / (1000 * 60 * 60 * 24));

    if (days === 0) {
      const hours = Math.floor(diff / (1000 * 60 * 60));
      if (hours === 0) {
        const minutes = Math.floor(diff / (1000 * 60));
        return minutes <= 1 ? "Just now" : `${minutes} minutes ago`;
      }
      return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
    }

    if (days === 1) return "Yesterday";
    if (days < 7) return `${days} days ago`;
    return date.toLocaleDateString();
  }

  function ensureStyles() {
    if (document.getElementById(IDS.style)) return;

    const style = document.createElement("style");
    style.id = IDS.style;

    style.textContent = `
#${IDS.overlay}.bb-notes-overlay{
  position: fixed;
  inset: 0;
  background: rgba(0,0,0,0.18);
  z-index: 9999;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 16px;
}

.bb-notes-modal{
  width: min(720px, 100%);
  max-height: 90vh;
  overflow: auto;
  background: var(--background-color);
  color: var(--text-color);
  border-radius: 6px;
  border: 1px solid rgba(0,0,0,0.12);
  box-shadow: 0 12px 34px rgba(0,0,0,0.14);
}

.bb-notes-head{
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 10px 12px;
  border-bottom: 1px solid rgba(0,0,0,0.10);
}

.bb-notes-title{
  font-weight: 700;
  color: var(--heading-color, var(--text-color));
}

.bb-notes-body{
  padding: 12px;
}

.bb-notes-meta{
  display:flex;
  justify-content: space-between;
  gap: 12px;
  font-size: 0.9rem;
  color: rgba(0,0,0,0.60);
  margin-bottom: 10px;
}

.bb-notes-text{
  width: 100%;
  min-height: 220px;
  box-sizing: border-box;
  resize: vertical;
  font: inherit;
  letter-spacing: inherit;
  padding: 0.7rem 0.8rem;
  border-radius: 6px;
  border: 1px solid rgba(0,0,0,0.18);
  background: var(--background-color);
  color: var(--text-color);
  outline: none;
}

.bb-notes-text:focus{
  border-color: rgba(0,122,255,0.55);
  box-shadow: 0 0 0 3px rgba(0,122,255,0.18);
}

.bb-notes-actions{
  display:flex;
  gap: 6px;
  flex-wrap: wrap;
  align-items: center;
  margin-top: 12px;
}

.bb-notes-hint{
  margin-left: auto;
  font-size: 0.85rem;
  color: rgba(0,0,0,0.55);
}

.bb-notes-actions button{
  margin: 0;
}

.bb-notes-confirm{
  display: none;
  align-items: center;
  gap: 8px;
  margin-top: 10px;
  padding: 8px 10px;
  border-radius: 6px;
  border: 1px solid rgba(0,0,0,0.12);
  background: rgba(0,0,0,0.04);
  font-size: 0.9rem;
}

.bb-notes-confirm.bb-notes-confirm--visible{
  display: flex;
}

.bb-notes-confirm-actions{
  margin-left: auto;
  display: flex;
  gap: 6px;
}

.bb-notes-open{
  margin-inline: 0.35rem;
}

.bb-notes-open--after-new{
  margin-left: 0.35rem;
}

@supports (color: color-mix(in srgb, black, white)){
  .bb-notes-modal{
    border-color: color-mix(in srgb, var(--text-color) 14%, transparent);
  }
  .bb-notes-head{
    border-bottom-color: color-mix(in srgb, var(--text-color) 12%, transparent);
  }
  .bb-notes-meta{
    color: color-mix(in srgb, var(--text-color) 65%, transparent);
  }
  .bb-notes-hint{
    color: color-mix(in srgb, var(--text-color) 60%, transparent);
  }
  .bb-notes-text{
    border-color: color-mix(in srgb, var(--text-color) 18%, transparent);
  }
  .bb-notes-text:focus{
    border-color: color-mix(in srgb, var(--link-color, #007aff) 45%, transparent);
    box-shadow: 0 0 0 3px color-mix(in srgb, var(--link-color, #007aff) 16%, transparent);
  }
}

@supports (color: color-mix(in srgb, black, white)){
  .bb-notes-confirm{
    border-color: color-mix(in srgb, var(--text-color) 14%, transparent);
    background: color-mix(in srgb, var(--text-color) 5%, transparent);
  }
}

@media (prefers-color-scheme: dark){
  #${IDS.overlay}.bb-notes-overlay{
    background: rgba(0,0,0,0.45);
  }
  .bb-notes-modal{
    box-shadow: 0 18px 50px rgba(0,0,0,0.55);
  }
  .bb-notes-meta{
    color: rgba(255,255,255,0.70);
  }
  .bb-notes-hint{
    color: rgba(255,255,255,0.65);
  }
  .bb-notes-text{
    border-color: rgba(255,255,255,0.20);
  }
  .bb-notes-confirm{
    border-color: rgba(255,255,255,0.18);
    background: rgba(255,255,255,0.06);
  }
}

@media (prefers-reduced-motion: reduce){
  .bb-notes-modal{
    scroll-behavior: auto;
  }
}
    `.trim();

    document.head.appendChild(style);
  }

  function setStatus(msg) {
    const overlay = getOverlay();
    if (!overlay) return;
    const s = qs(".bb-notes-status", overlay);
    if (s) s.textContent = msg;
  }

  function statusThenReset(msg, delay) {
    window.clearTimeout(statusResetTimer);
    setStatus(msg);
    statusResetTimer = window.setTimeout(() => {
      setStatus("Ready.");
      statusResetTimer = null;
    }, delay || STATUS_RESET_DELAY);
  }

  function focusFirstField(overlay) {
    const ta = qs(".bb-notes-text", overlay);
    if (ta) ta.focus();
  }

  function getFocusable(overlay) {
    const nodes = overlay.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    return Array.from(nodes).filter((el) => !el.disabled && el.offsetParent !== null);
  }

  function trapFocus(e) {
    if (e.key !== "Tab") return;

    const overlay = getOverlay();
    if (!overlay || overlay.style.display !== "flex") return;

    const focusables = getFocusable(overlay);
    if (!focusables.length) return;

    const first = focusables[0];
    const last = focusables[focusables.length - 1];

    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
      return;
    }

    if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }

  function hideConfirm() {
    const overlay = getOverlay();
    if (!overlay) return;
    const confirmBar = qs(".bb-notes-confirm", overlay);
    if (confirmBar) confirmBar.classList.remove("bb-notes-confirm--visible");
  }

  function renderModal() {
    const overlay = getOverlay();
    if (!overlay) return;

    const ta = qs(".bb-notes-text", overlay);
    const mod = qs(".bb-notes-modified", overlay);

    const obj = getNoteObject();
    if (ta) ta.value = obj.content || "";
    if (mod) mod.textContent = obj.modified ? `Modified ${formatDate(obj.modified)}` : "";

    openSnapshot = obj.content || "";
    setStatus("Ready.");
    hideConfirm();
  }

  function buildModalIfNeeded() {
    let overlay = getOverlay();
    if (overlay) return overlay;

    ensureStyles();

    overlay = document.createElement("div");
    overlay.id = IDS.overlay;
    overlay.className = "bb-notes-overlay";

    overlay.innerHTML = `
<div class="bb-notes-modal" role="dialog" aria-modal="true" aria-labelledby="${IDS.title}">
  <div class="bb-notes-head">
    <div class="bb-notes-title" id="${IDS.title}">Notes</div>
    <button type="button" class="bb-notes-close" aria-label="Close notes" title="Close">Close</button>
  </div>
  <div class="bb-notes-body">
    <div class="bb-notes-meta">
      <span class="bb-notes-status" id="${IDS.status}" role="status" aria-live="polite">Ready.</span>
      <span class="bb-notes-modified"></span>
    </div>
    <textarea class="bb-notes-text" placeholder="Small reminders, ideas, stuff you don’t want to forget…" aria-label="Notes text"></textarea>
    <div class="bb-notes-actions">
      <button type="button" class="bb-notes-copy" aria-label="Copy notes" title="Copy" aria-controls="${IDS.status}">Copy</button>
      <button type="button" class="bb-notes-clear" aria-label="Clear notes" title="Clear" aria-controls="bb-notes-confirm">Clear</button>
      <div class="bb-notes-hint">Autosaves while typing.</div>
    </div>
    <div class="bb-notes-confirm" id="bb-notes-confirm" role="status" aria-live="polite">
      <span>Clear the note?</span>
      <div class="bb-notes-confirm-actions">
        <button type="button" class="bb-notes-confirm-yes">Clear</button>
        <button type="button" class="bb-notes-confirm-no">Cancel</button>
      </div>
    </div>
  </div>
</div>
    `.trim();

    document.body.appendChild(overlay);

    overlay.addEventListener("click", (e) => {
      if (e.target === overlay) api.close();
    });

    const closeBtn = qs(".bb-notes-close", overlay);
    const copyBtn = qs(".bb-notes-copy", overlay);
    const clearBtn = qs(".bb-notes-clear", overlay);
    const confirmYesBtn = qs(".bb-notes-confirm-yes", overlay);
    const confirmNoBtn = qs(".bb-notes-confirm-no", overlay);

    // Preventing default on mousedown keeps focus on whatever it already
    // was (usually the textarea), avoiding a focus-stealing flash before
    // the click handler runs.
    [closeBtn, copyBtn, clearBtn, confirmYesBtn, confirmNoBtn].forEach((btn) => {
      btn.addEventListener("mousedown", (e) => e.preventDefault());
    });

    closeBtn.addEventListener("click", api.close);
    copyBtn.addEventListener("click", api.copy);
    clearBtn.addEventListener("click", api.requestClear);
    confirmYesBtn.addEventListener("click", api.clear);
    confirmNoBtn.addEventListener("click", api.cancelClear);

    const ta = qs(".bb-notes-text", overlay);
    ta.addEventListener("input", () => {
      setStatus("Typing…");
      window.clearTimeout(autosaveTimer);
      autosaveTimer = window.setTimeout(() => api.save({ quiet: true }), AUTOSAVE_DELAY);
    });

    return overlay;
  }

  async function copyTextWithFallback(text, textareaEl) {
    if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
      try {
        await navigator.clipboard.writeText(text);
        return true;
      } catch (err) {
        console.error("[Dashboard notes] Clipboard API failed, falling back", err);
      }
    }

    if (!textareaEl) return false;

    try {
      textareaEl.focus();
      textareaEl.select();
      return !!document.execCommand("copy");
    } catch (err) {
      console.error("[Dashboard notes] Fallback copy failed", err);
      return false;
    } finally {
      try {
        textareaEl.setSelectionRange(text.length, text.length);
      } catch {}
    }
  }

  function getTextareaValue() {
    const overlay = getOverlay();
    if (!overlay) return null;
    const ta = qs(".bb-notes-text", overlay);
    return ta ? ta.value : null;
  }

  function hasChanges() {
    const v = getTextareaValue();
    if (v === null) return false;
    return v !== openSnapshot;
  }

  api.open = function () {
    const overlay = buildModalIfNeeded();
    lastFocusEl = document.activeElement;

    renderModal();
    overlay.style.display = "flex";
    window.setTimeout(() => focusFirstField(overlay), 0);
  };

  api.close = function () {
    const overlay = getOverlay();
    if (!overlay) return;

    window.clearTimeout(autosaveTimer);
    autosaveTimer = null;
    window.clearTimeout(statusResetTimer);
    statusResetTimer = null;

    if (hasChanges()) api.save({ quiet: true, skipNoopStatus: true });

    overlay.style.display = "none";

    if (lastFocusEl && typeof lastFocusEl.focus === "function") {
      try {
        lastFocusEl.focus();
      } catch {}
    }
    lastFocusEl = null;
  };

  api.save = function (opts) {
    const overlay = getOverlay();
    if (!overlay) return;

    const ta = qs(".bb-notes-text", overlay);
    const mod = qs(".bb-notes-modified", overlay);

    const current = ta ? ta.value : "";
    if (current === openSnapshot) {
      if (opts && opts.quiet && !opts.skipNoopStatus) {
        setStatus("Ready.");
      }
      return;
    }

    const obj = getNoteObject();
    obj.content = current;
    obj.modified = new Date().toISOString();

    const ok = setNoteObject(obj);
    if (!ok) {
      setStatus("Could not save (storage blocked).");
      return;
    }

    openSnapshot = current;

    if (mod) mod.textContent = `Modified ${formatDate(obj.modified)}`;

    if (opts && opts.quiet) {
      if (!opts.skipNoopStatus) {
        statusThenReset("Autosaved.");
      }
      return;
    }

    statusThenReset("Saved.");
  };

  api.copy = async function () {
    const overlay = getOverlay();
    if (!overlay) return;

    const ta = qs(".bb-notes-text", overlay);
    const text = ta ? ta.value : "";

    const ok = await copyTextWithFallback(text, ta);
    statusThenReset(ok ? "Copied." : "Could not copy.");
  };

  api.requestClear = function () {
    const overlay = getOverlay();
    if (!overlay) return;
    const confirmBar = qs(".bb-notes-confirm", overlay);
    if (confirmBar) {
      confirmBar.classList.add("bb-notes-confirm--visible");
      const yesBtn = qs(".bb-notes-confirm-yes", overlay);
      if (yesBtn) yesBtn.focus();
    }
  };

  api.cancelClear = function () {
    hideConfirm();
    const overlay = getOverlay();
    if (overlay) {
      const clearBtn = qs(".bb-notes-clear", overlay);
      if (clearBtn) clearBtn.focus();
    }
  };

  api.clear = function () {
    window.clearTimeout(autosaveTimer);
    autosaveTimer = null;

    const obj = getNoteObject();
    obj.content = "";
    obj.modified = new Date().toISOString();

    const ok = setNoteObject(obj);
    if (!ok) {
      hideConfirm();
      setStatus("Could not clear (storage blocked).");
      return;
    }

    openSnapshot = "";
    renderModal();
    statusThenReset("Cleared.");
  };

  function addNotesButtonToControls() {
    const controls = qs(".sticky-controls");
    if (!controls) return;
    if (qs(".bb-notes-open", controls)) return;

    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "bb-notes-open";
    btn.textContent = "Notes";
    btn.setAttribute("aria-label", "Open notes");
    btn.title = "Open notes";
    btn.addEventListener("mousedown", (e) => e.preventDefault());
    btn.addEventListener("click", (e) => {
      e.preventDefault();
      api.open();
    });

    controls.appendChild(btn);
  }

  function addNotesButtonNextToNew() {
    const newBtn = qs('main a[href*="/new/"]');
    if (!newBtn || !newBtn.parentNode) return;
    if (qs(".bb-notes-open", newBtn.parentNode)) return;

    const btn = document.createElement("button");
    btn.type = "button";
    btn.className = "bb-notes-open bb-notes-open--after-new";
    btn.textContent = "Notes";
    btn.setAttribute("aria-label", "Open notes");
    btn.title = "Open notes";
    btn.addEventListener("mousedown", (e) => e.preventDefault());
    btn.addEventListener("click", (e) => {
      e.preventDefault();
      api.open();
    });

    newBtn.parentNode.insertBefore(btn, newBtn.nextSibling);
  }

  function onKeydown(e) {
    const overlay = getOverlay();
    const open = !!overlay && overlay.style.display === "flex";

    if (open) trapFocus(e);

    if (e.key === "Escape" && open) {
      e.preventDefault();
      const confirmBar = qs(".bb-notes-confirm", overlay);
      if (confirmBar && confirmBar.classList.contains("bb-notes-confirm--visible")) {
        api.cancelClear();
        return;
      }
      api.close();
      return;
    }

    if ((e.ctrlKey || e.metaKey) && (e.key === "s" || e.key === "S") && open) {
      e.preventDefault();
      api.save();
      return;
    }

    if ((e.ctrlKey || e.metaKey) && (e.key === "k" || e.key === "K")) {
      if (!isDashboard()) return;
      if (open) return;
      e.preventDefault();
      api.open();
    }
  }

  function init() {
    if (!isDashboard()) return;

    addNotesButtonToControls();
    addNotesButtonNextToNew();
    document.addEventListener("keydown", onKeydown, { passive: false });
  }

  if (document.readyState === "loading") {
    document.addEventListener("DOMContentLoaded", init);
  } else {
    init();
  }
})();
</script>

Want more? Check out the full Bear Blog library.

  1. Requires JavaScript, available with Bear Blog subscription.