Robert Birming

Bear /now dashboard plugin

I’ve always been terrible at updating my /now page. I forget about it.

One of the reasons is that it moves further and further down in my dashboard. I create themes, add-ons, and other little projects. I have to scroll to find it in my digital jungle.

So, in an attempt to try to keep my now page better updated, I set out to create a plugin to help me with that.

The Bear dashboard menu with the new Now link between Pages and Themes

After installing it, you’ll get a new “Now” menu option in the Bear dashboard. Clicking it takes you straight to the edit screen. Easy peasy!

The editor's bottom row with Now cards first, before Insert media

Here you will also find a new “Now cards” option. Hit it and a card opens with options to easily add what you’re currently reading, watching, listening to, or playing.

The Now cards window with a book card filled in and a preview of the Markdown

Will this help me get better at keeping my page fresh? I don’t know, but at least I’ve tried my best...

Installation

Requires JavaScript, available with Bear Blog subscription.

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

If your now page isn’t called “Now”, change NOW_TITLE at the top of the script.

Script

<script>
/* Now cards · v1.0 · Bear dashboard plugin by Robert Birming */
(() => {
  'use strict';

  const NOW_TITLE = 'Now'; // your now page's title, if it isn't "Now"
  const KEY = 'now-cards-edit-url';

  // Line format: *Title* + pre + extra + post (extra is optional)
  const CARDS = {
    book:  { label: 'Book',  emoji: '📖', statuses: ['Currently reading', 'Finished reading'],   title: 'Title',          extra: 'Author',   pre: ' by ' },
    movie: { label: 'Movie', emoji: '🎬', statuses: ['Just watched', 'Want to watch'],           title: 'Title',          extra: 'Year',     pre: ' (', post: ')', numeric: true, max: 4 },
    show:  { label: 'Show',  emoji: '📺', statuses: ['Currently watching', 'Finished watching'], title: 'Title',          extra: 'Season',   pre: ', season ', numeric: true, max: 3 },
    game:  { label: 'Game',  emoji: '🎮', statuses: ['Currently playing', 'Finished playing'],   title: 'Title',          extra: 'Platform', pre: ' on ' },
    music: { label: 'Music', emoji: '🎧', statuses: ['Listening to', 'On repeat'],               title: 'Album or track', extra: 'Artist',   pre: ' by ' },
  };

  /* ---------- helpers ---------- */

  const lc = s => s.trim().toLowerCase();
  const esc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const isNowTitle = t => /(^|[\s/])now( page)?$/.test(lc(t));
  const store = {
    get() { try { return localStorage.getItem(KEY); } catch { return null; } },
    set(v) { try { localStorage.setItem(KEY, v); } catch {} },
  };

  // A card = its heading line (bold or not) plus the lines right after it,
  // up to a blank line, a Markdown heading, or the next card's heading
  const ALL_EMOJI = Object.values(CARDS).map(c => esc(c.emoji)).join('|');
  const cardRe = type => {
    const c = CARDS[type];
    return new RegExp(
      `^[ \\t]*(?:\\*\\*)?${esc(c.emoji)} (?:${c.statuses.map(esc).join('|')})(?:\\*\\*)?[ \\t]*$` +
      `(?:\\n(?![ \\t]*(?:#|(?:\\*\\*)?(?:${ALL_EMOJI}) ))[ \\t]*\\S.*)*`,
      'm'
    );
  };

  function readCard(text, type) {
    const m = text.match(cardRe(type));
    if (!m) return null;
    const c = CARDS[type];
    const lines = m[0].split('\n').map(s => s.trim());
    const pre = c.pre || '';
    const post = c.post || '';
    const line = lines[1] || '';
    let title = line;
    let extra = '';

    const it = line.match(/^\*([^*]+)\*(.*)$/);
    if (it) {
      title = it[1];
      const rest = it[2];
      if (rest.startsWith(pre) && rest.endsWith(post) && rest.length > pre.length + post.length) {
        extra = rest.slice(pre.length, rest.length - post.length);
      }
    } else if (line.includes(' — ')) {
      // older hand-written style: Title — Creator
      const i = line.lastIndexOf(' — ');
      title = line.slice(0, i);
      extra = line.slice(i + 3);
    }

    return {
      status: c.statuses.find(s => lines[0].includes(s)) || c.statuses[0],
      title,
      extra,
      note: lines.slice(2).join(' '),
    };
  }

  function renderCard(type, v) {
    const c = CARDS[type];
    const line = `*${v.title}*` + (v.extra ? `${c.pre || ''}${v.extra}${c.post || ''}` : '');
    return [`**${c.emoji} ${v.status}**`, line, v.note].filter(Boolean).join('\n');
  }

  /* ---------- 1. "Now" link in the main nav ---------- */

  function addNavLink() {
    const nav = document.querySelector('header nav span');
    const home = nav && nav.querySelector('a[href$="/dashboard/"]');
    if (!home) return;

    const saved = store.get();
    const base = home.getAttribute('href');
    const pages = nav.querySelector(`a[href="${base}pages/"]`);
    const link = document.createElement('a');
    link.textContent = 'Now';
    link.href = saved || base + 'pages/';
    (pages || nav.lastElementChild).after(' ', link);

    if (saved) return;

    fetch(base + 'pages/', { credentials: 'same-origin' })
      .then(r => (r.ok ? r.text() : Promise.reject()))
      .then(html => {
        const doc = new DOMParser().parseFromString(html, 'text/html');
        const links = [...doc.querySelectorAll('main a')];
        const match =
          links.find(a => lc(a.textContent) === lc(NOW_TITLE)) ||
          links.find(a => isNowTitle(a.textContent));
        if (match) {
          const url = match.getAttribute('href');
          store.set(url);
          link.href = url;
        }
      })
      .catch(() => {});
  }

  /* ---------- 2. Now cards in the now page editor ---------- */

  function initEditor() {
    const body =
      document.querySelector('textarea[name="body_content"]') ||
      document.querySelector('main form textarea');
    if (!body) return;

    const headerText =
      document.querySelector('[name="header_content"]')?.value ||
      document.querySelector('.editor-header')?.innerText ||
      '';
    const byHeader = /^link:\s*\/?now\/?\s*$/im.test(headerText);
    if (!byHeader && store.get() !== location.pathname) return;
    // Refresh the saved URL whenever the header confirms this is the now page,
    // so a recreated page doesn't leave the nav link pointing at the old one
    if (byHeader) store.set(location.pathname);

    // Only use the cursor position if you've actually clicked, typed or pasted in the editor
    // (not on focus: if Bear focuses the editor on load, the cursor would sit at the very top)
    let touched = false;
    ['click', 'keyup', 'input'].forEach(e => body.addEventListener(e, () => { touched = true; }));

    // Trigger, first in the bottom row
    const trigger = document.createElement('button');
    trigger.type = 'button';
    trigger.className = 'link-button nc-trigger';
    trigger.textContent = 'Now cards';
    const insertMedia = [...document.querySelectorAll('a, button')]
      .find(el => lc(el.textContent) === 'insert media');
    if (insertMedia) insertMedia.before(trigger, ' | ');
    else (document.querySelector('.helptext') || body.parentElement).append(' | ', trigger);

    // Dialog
    const dlg = document.createElement('dialog');
    dlg.className = 'now-cards';
    dlg.setAttribute('aria-labelledby', 'nc-heading');
    dlg.innerHTML = `
      <form method="dialog">
        <div class="nc-head">
          <h2 id="nc-heading">Now cards</h2>
          <button type="button" class="nc-close" aria-label="Close">✕</button>
        </div>
        <div class="nc-types" role="group" aria-label="Card type"></div>
        <div class="nc-fields"></div>
        <div>
          <p class="nc-label">Preview</p>
          <pre class="nc-preview" aria-live="polite"></pre>
        </div>
        <div class="nc-actions">
          <button type="button" class="nc-close">Cancel</button>
          <button value="apply">Apply</button>
        </div>
      </form>`;
    document.body.append(dlg);

    const form = dlg.querySelector('form');
    const types = dlg.querySelector('.nc-types');
    const fields = dlg.querySelector('.nc-fields');
    const preview = dlg.querySelector('.nc-preview');
    let current = null;
    let pending = null;

    const val = name => form.querySelector(`[name="${name}"]`).value.trim().replace(/\s+/g, ' ');
    const values = () => ({
      status: form.querySelector('[name="nc-status"]:checked')?.value || CARDS[current].statuses[0],
      title: val('nc-title'),
      extra: val('nc-extra'),
      note: val('nc-note'),
    });

    function update() {
      const v = values();
      preview.textContent = v.title ? renderCard(current, v) : `${CARDS[current].title} is needed`;
      preview.classList.toggle('muted', !v.title);
    }

    function select(type) {
      current = type;
      types.querySelectorAll('[data-type]').forEach(b =>
        b.setAttribute('aria-pressed', String(b.dataset.type === type)));

      const c = CARDS[type];
      const v = readCard(body.value, type) || { status: c.statuses[0], title: '', extra: '', note: '' };
      const numeric = c.numeric ? ` inputmode="numeric" pattern="[0-9]*" maxlength="${c.max}"` : '';

      fields.innerHTML =
        `<fieldset><legend>Status</legend>${c.statuses.map(s =>
          `<label><input type="radio" name="nc-status" value="${s}"> ${s}</label>`).join('')}</fieldset>` +
        `<label>${c.title}<input name="nc-title" required autocomplete="off"></label>` +
        `<label>${c.extra} <span class="muted">(optional)</span><input name="nc-extra" autocomplete="off"${numeric}></label>` +
        `<label>Note <span class="muted">(optional)</span><input name="nc-note" autocomplete="off" maxlength="200"></label>`;

      const radio = [...fields.querySelectorAll('[name="nc-status"]')].find(r => r.value === v.status);
      (radio || fields.querySelector('[name="nc-status"]')).checked = true;
      fields.querySelector('[name="nc-title"]').value = v.title;
      fields.querySelector('[name="nc-extra"]').value = v.extra;
      fields.querySelector('[name="nc-note"]').value = v.note;
      update();
    }

    trigger.addEventListener('click', () => {
      const text = body.value;
      types.innerHTML = Object.entries(CARDS).map(([k, c]) => {
        const onPage = cardRe(k).test(text);
        return `<button type="button" data-type="${k}"${onPage ? ' title="On the page"' : ''}>${c.emoji} ${c.label}${onPage ? ' •' : ''}</button>`;
      }).join('');
      const first = Object.keys(CARDS).find(k => cardRe(k).test(text)) || 'book';
      select(current || first);
      dlg.returnValue = '';
      dlg.showModal();
      fields.querySelector('[name="nc-title"]').focus();
    });

    types.addEventListener('click', e => {
      const b = e.target.closest('[data-type]');
      if (b) select(b.dataset.type);
    });
    fields.addEventListener('input', update);
    dlg.querySelectorAll('.nc-close').forEach(b => b.addEventListener('click', () => dlg.close()));

    form.addEventListener('submit', () => {
      const v = values();
      if (!v.title) { pending = null; return; }
      const card = renderCard(current, v);
      const text = body.value;
      const m = text.match(cardRe(current));

      if (m) {
        pending = { start: m.index, end: m.index + m[0].length, str: card, offset: 0 };
      } else {
        const pos = touched ? body.selectionStart : text.length;
        const before = text.slice(0, pos);
        const after = text.slice(pos);
        const pre = !before || before.endsWith('\n\n') ? '' : before.endsWith('\n') ? '\n' : '\n\n';
        const post = !after ? '\n' : after.startsWith('\n\n') ? '' : after.startsWith('\n') ? '\n' : '\n\n';
        pending = { start: pos, end: pos, str: pre + card + post, offset: pre.length };
      }
    });

    // Write after the dialog has closed, so the editor can take focus (and undo works)
    dlg.addEventListener('close', () => {
      if (dlg.returnValue !== 'apply' || !pending) { pending = null; return; }
      const { start, end, str, offset } = pending;
      pending = null;

      body.focus();
      body.setSelectionRange(start, end);
      if (!document.execCommand('insertText', false, str)) {
        body.setRangeText(str, start, end, 'end');
        body.dispatchEvent(new Event('input', { bubbles: true }));
      }
      const cardStart = start + offset;
      body.setSelectionRange(cardStart, cardStart);
      body.blur();
      body.focus();
    });

    // Styles, using Bear's own dashboard variables
    const style = document.createElement('style');
    style.textContent = `
      button.nc-trigger.link-button:not(:disabled):hover { background: none; text-decoration: underline; }
      dialog.now-cards { width: min(30rem, calc(100% - 2rem)); padding: 0; border: 1px solid var(--border); border-radius: var(--radius, 0); background: var(--background-color); color: var(--text-color); }
      dialog.now-cards::backdrop { background: rgb(0 0 0 / 0.45); }
      .now-cards form { display: grid; gap: 1rem; padding: 1rem 1.25rem 1.25rem; }
      .now-cards .nc-head { display: flex; justify-content: space-between; align-items: center; }
      .now-cards h2 { margin: 0; font-size: 1.15em; }
      .now-cards .nc-types { display: flex; flex-wrap: wrap; gap: 0.4rem; }
      .now-cards .nc-types [aria-pressed="true"] { border-color: var(--link-color); color: var(--link-color); }
      .now-cards .nc-fields { display: grid; gap: 0.75rem; }
      .now-cards .nc-fields > label { display: grid; gap: 0.25rem; }
      .now-cards .nc-fields .muted { font-weight: normal; font-size: small; }
      .now-cards fieldset { border: 0; padding: 0; margin: 0; }
      .now-cards legend { font-weight: bold; padding: 0; margin-bottom: 0.25rem; }
      .now-cards fieldset label { display: inline-flex; align-items: center; margin-right: 1rem; font-weight: normal; }
      .now-cards input:not([type="radio"]) { width: 100%; box-sizing: border-box; padding: 6px 10px; font-size: 16px; }
      .now-cards .nc-label { margin: 0 0 0.25rem; font-weight: bold; }
      .now-cards .nc-preview { margin: 0; padding: 0.75rem; background: var(--surface); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; }
      .now-cards .nc-actions { display: flex; justify-content: flex-end; gap: 0.5rem; }
    `;
    document.head.append(style);
  }

  addNavLink();
  initEditor();
})();
</script>

Now go update your now page.

Want more? Check out the full Bear Blog library.