Robert Birming

Bear gallery upload plugin

I read Harry's post Bear Community, I need your wisdom and thought to myself, "What an awesome Sunday tinkering challenge". This is what I came up with:

A Bear dashboard gallery plugin.

After adding the script to your dashboard, you'll see a new "Create gallery" link when drafting posts and pages. Clicking it opens a window where you can upload, rearrange, and add captions to your photos. I've tried to make it feel as native to Bear as possible.

There's also an option to wrap the images in a Bearming gallery div to make it work with the Lightbox photo gallery add-on. That add-on has also been updated with support for captions and some neat new touches.

Below are the instructions for adding the dashboard gallery plugin, and here's a short video to show you how it looks.

Update

Harry

Harry's reaction when I told him about the plugin. 😍


Installation

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

Script

<script>
/*
 Plugin name: Gallery upload
 Description: Upload multiple photos and insert them as a gallery.
 Author: Robert Birming
 Author URI: https://robertbirming.com
*/

(function () {
    'use strict';

    document.addEventListener('DOMContentLoaded', function () {
        const $body = document.getElementById('body_content');
        const $insertMedia = document.getElementById('upload-image');

        if (!$body || !$insertMedia) return;

        const blogSlug = window.location.pathname.split('/').filter(Boolean)[0];
        const uploadUrl = '/' + blogSlug + '/dashboard/upload-image/';

        function csrfToken() {
            const el = document.querySelector('[name=csrfmiddlewaretoken]');
            return el ? el.value : '';
        }

        // Unique ID per placeholder - avoids any collision risk with duplicate filenames
        function generateId() {
            if (typeof crypto !== 'undefined' && crypto.randomUUID) {
                return crypto.randomUUID();
            }
            return Date.now().toString(36) + Math.random().toString(36).slice(2);
        }

        // Escape user-supplied caption text before inserting into HTML
        function escapeHtml(str) {
            return str
                .replace(/&/g, '&amp;')
                .replace(/</g, '&lt;')
                .replace(/>/g, '&gt;')
                .replace(/"/g, '&quot;');
        }

        // Track cursor position on blur, click, and keyup for reliability
        let savedCursorPos = $body.value.length;
        ['blur', 'click', 'keyup'].forEach(function (eventName) {
            $body.addEventListener(eventName, function () {
                savedCursorPos = this.selectionStart;
            });
        });

        // --- Inject "Create gallery" into the native link bar ---

        const $bar = $insertMedia.parentElement;
        const $sep = document.createTextNode(' | ');
        const $galleryLink = document.createElement('a');

        $galleryLink.href = '#';
        $galleryLink.textContent = 'Create gallery';
        $galleryLink.addEventListener('click', function (e) {
            e.preventDefault();
            openModal();
        });

        $bar.insertBefore($sep, $insertMedia);
        $bar.insertBefore($galleryLink, $sep);

        // --- Styles ---

        const $style = document.createElement('style');
        $style.textContent = `
            #gu-overlay {
                position: fixed;
                inset: 0;
                background: rgba(0, 0, 0, 0.3);
                z-index: 9999;
                display: none;
                align-items: center;
                justify-content: center;
                padding: 20px;
                box-sizing: border-box;
            }
            #gu-modal {
                width: 100%;
                max-width: 640px;
                max-height: 90vh;
                overflow-y: auto;
                background: var(--background-color, #fff);
                color: var(--text-color, #444);
                border: 1px solid lightgrey;
                box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2);
            }
            #gu-head {
                display: flex;
                align-items: center;
                justify-content: space-between;
                padding: 12px 16px;
                border-bottom: 1px solid lightgrey;
            }
            #gu-body {
                padding: 16px;
            }
            #gu-file-wrap {
                margin-bottom: 12px;
            }
            #gu-file-wrap input[type="file"] {
                width: auto !important;
                background: none !important;
                padding: 0 !important;
                font-size: inherit;
                line-height: inherit;
            }
            #gu-hint {
                font-size: 0.9em;
                font-weight: normal;
                opacity: 0.6;
                margin: 0 0 10px;
                display: none;
            }
            #gu-thumbs {
                display: flex;
                flex-wrap: wrap;
                gap: 8px;
                margin-bottom: 12px;
                min-height: 1px;
            }
            #gu-thumbs.gu-uploading {
                pointer-events: none;
            }
            .gu-thumb {
                position: relative;
                width: 96px;
                flex-shrink: 0;
                cursor: grab;
            }
            .gu-thumb img {
                width: 96px;
                height: 96px;
                object-fit: cover;
                display: block;
                pointer-events: none;
            }
            .gu-thumb-name {
                font-size: 9px;
                opacity: 0.55;
                white-space: nowrap;
                overflow: hidden;
                text-overflow: ellipsis;
                width: 96px;
                margin-top: 3px;
            }
            .gu-thumb-caption {
                width: 96px;
                margin-top: 4px;
                font-size: 12px !important;
                line-height: 1.3 !important;
                padding: 3px 5px !important;
                background: none !important;
                border: 1px solid rgba(128, 128, 128, 0.35) !important;
                border-radius: 2px;
                color: inherit !important;
                box-sizing: border-box;
                cursor: text;
            }
            .gu-thumb-caption::placeholder {
                font-size: 12px;
                opacity: 0.45;
            }
            .gu-badge {
                position: absolute;
                top: 3px;
                right: 3px;
                width: 18px;
                height: 18px;
                border-radius: 50%;
                font-size: 11px;
                font-weight: bold;
                display: none;
                align-items: center;
                justify-content: center;
                color: #fff;
            }
            #gu-opt {
                display: flex;
                align-items: center;
                gap: 8px;
                font-size: 0.9em;
                font-weight: normal;
                margin-bottom: 12px;
                cursor: pointer;
            }
            #gu-opt input {
                width: auto !important;
                background: none !important;
                padding: 0 !important;
                margin: 0;
            }
            #gu-actions {
                display: flex;
                align-items: center;
                gap: 12px;
                flex-wrap: wrap;
            }
            #gu-status {
                font-size: 0.85em;
                opacity: 0.6;
            }
            @media (prefers-color-scheme: dark) {
                #gu-overlay {
                    background: rgba(0, 0, 0, 0.5);
                }
                #gu-modal,
                #gu-head {
                    border-color: rgba(255, 255, 255, 0.12);
                }
            }
        `;
        document.head.appendChild($style);

        // --- Build modal ---

        const $overlay = document.createElement('div');
        $overlay.id = 'gu-overlay';

        const $modal = document.createElement('div');
        $modal.id = 'gu-modal';

        const $head = document.createElement('div');
        $head.id = 'gu-head';

        const $title = document.createElement('strong');
        $title.textContent = 'Gallery upload';

        const $closeBtn = document.createElement('button');
        $closeBtn.type = 'button';
        $closeBtn.textContent = 'Close';
        $closeBtn.addEventListener('click', closeModal);

        $head.appendChild($title);
        $head.appendChild($closeBtn);

        const $modalBody = document.createElement('div');
        $modalBody.id = 'gu-body';

        const $fileWrap = document.createElement('div');
        $fileWrap.id = 'gu-file-wrap';

        const $fileInput = document.createElement('input');
        $fileInput.type = 'file';
        $fileInput.multiple = true;
        $fileInput.accept = 'image/*';

        $fileWrap.appendChild($fileInput);

        const $hint = document.createElement('p');
        $hint.id = 'gu-hint';
        $hint.textContent = 'Drag to reorder · Captions optional';

        const $thumbs = document.createElement('div');
        $thumbs.id = 'gu-thumbs';

        const $optLabel = document.createElement('label');
        $optLabel.id = 'gu-opt';

        const $wrapCheck = document.createElement('input');
        $wrapCheck.type = 'checkbox';
        $wrapCheck.checked = true;

        $optLabel.appendChild($wrapCheck);
        $optLabel.appendChild(document.createTextNode('Wrap in bearming-gallery div'));

        const $actions = document.createElement('div');
        $actions.id = 'gu-actions';

        const $btn = document.createElement('button');
        $btn.type = 'button';
        $btn.textContent = 'Upload';
        $btn.disabled = true;

        const $status = document.createElement('span');
        $status.id = 'gu-status';

        $actions.appendChild($btn);
        $actions.appendChild($status);

        $modalBody.appendChild($fileWrap);
        $modalBody.appendChild($hint);
        $modalBody.appendChild($thumbs);
        $modalBody.appendChild($optLabel);
        $modalBody.appendChild($actions);

        $modal.appendChild($head);
        $modal.appendChild($modalBody);
        $overlay.appendChild($modal);
        document.body.appendChild($overlay);

        $overlay.addEventListener('click', function (e) {
            if (e.target === $overlay) closeModal();
        });

        document.addEventListener('keydown', function (e) {
            if (e.key === 'Escape' && $overlay.style.display === 'flex') closeModal();
        });

        let pendingXhrs = [];
        let uploading = false;

        function openModal() {
            files = [];
            captions = [];
            pendingXhrs = [];
            $thumbs.innerHTML = '';
            $thumbs.classList.remove('gu-uploading');
            $hint.style.display = 'none';
            $status.textContent = '';
            $btn.disabled = true;
            $fileInput.value = '';
            $fileInput.disabled = false;
            $closeBtn.disabled = false;
            $overlay.style.display = 'flex';
        }

        function closeModal() {
            pendingXhrs.forEach(function (xhr) { xhr.abort(); });
            pendingXhrs = [];
            uploading = false;
            $overlay.style.display = 'none';
        }

        // --- State ---

        let files = [];
        let captions = [];
        let dragSrcIdx = null;

        // --- File selection ---

        $fileInput.addEventListener('change', function (e) {
            e.stopImmediatePropagation();
            e.stopPropagation();
            files = Array.from(this.files);
            captions = new Array(files.length).fill('');
            renderThumbs();
            $btn.disabled = files.length === 0;
            $hint.style.display = files.length ? 'block' : 'none';
            $status.textContent = files.length
                ? files.length + ' photo' + (files.length > 1 ? 's' : '') + ' selected'
                : '';
        }, true);

        // Rebuild files/captions arrays from current DOM order after a drag,
        // without wiping the DOM - keeps dragend from firing on detached nodes.

        function reindexThumbs() {
            const $all = $thumbs.querySelectorAll('.gu-thumb');
            const newFiles = [];
            const newCaptions = [];
            $all.forEach(function ($wrap, newIdx) {
                const oldIdx = parseInt($wrap.dataset.idx);
                newFiles.push(files[oldIdx]);
                newCaptions.push(captions[oldIdx]);
                $wrap.dataset.idx = String(newIdx);
            });
            files = newFiles;
            captions = newCaptions;
        }

        // --- Thumbnails with drag-to-reorder and optional captions ---

        function renderThumbs() {
            $thumbs.innerHTML = '';
            files.forEach(function (file, i) {
                const $wrap = document.createElement('div');
                $wrap.className = 'gu-thumb';
                $wrap.dataset.idx = String(i);
                $wrap.draggable = true;

                const $img = document.createElement('img');
                const objectUrl = URL.createObjectURL(file);
                $img.src = objectUrl;
                $img.alt = file.name;
                $img.onload = function () { URL.revokeObjectURL(objectUrl); };

                const $name = document.createElement('div');
                $name.className = 'gu-thumb-name';
                $name.textContent = file.name;

                const $captionInput = document.createElement('input');
                $captionInput.type = 'text';
                $captionInput.className = 'gu-thumb-caption';
                $captionInput.placeholder = 'Caption';
                $captionInput.value = captions[i] || '';

                // Use dataset.idx rather than closure i - stays correct after reindexing
                $captionInput.addEventListener('input', function () {
                    captions[parseInt($wrap.dataset.idx)] = this.value;
                });
                $captionInput.addEventListener('mousedown', function (e) {
                    e.stopPropagation();
                });
                $captionInput.addEventListener('dragstart', function (e) {
                    e.stopPropagation();
                    e.preventDefault();
                });

                const $badge = document.createElement('div');
                $badge.className = 'gu-badge';

                $wrap.addEventListener('dragstart', function (e) {
                    dragSrcIdx = parseInt(this.dataset.idx);
                    e.dataTransfer.effectAllowed = 'move';
                    const el = this;
                    setTimeout(function () { el.style.opacity = '0.4'; }, 0);
                });

                $wrap.addEventListener('dragend', function () {
                    this.style.opacity = '';
                    clearHighlights();
                });

                $wrap.addEventListener('dragover', function (e) {
                    e.preventDefault();
                    e.dataTransfer.dropEffect = 'move';
                    clearHighlights();
                    this.style.outline = '2px solid currentColor';
                    this.style.outlineOffset = '2px';
                });

                $wrap.addEventListener('dragleave', function () {
                    this.style.outline = '';
                    this.style.outlineOffset = '';
                });

                $wrap.addEventListener('drop', function (e) {
                    e.preventDefault();
                    const dropIdx = parseInt(this.dataset.idx);
                    if (dragSrcIdx !== null && dragSrcIdx !== dropIdx) {
                        const $src = $thumbs.querySelector('[data-idx="' + dragSrcIdx + '"]');
                        if ($src) {
                            if (dropIdx < dragSrcIdx) {
                                $thumbs.insertBefore($src, this);
                            } else {
                                $thumbs.insertBefore($src, this.nextSibling);
                            }
                            reindexThumbs();
                        }
                    }
                    dragSrcIdx = null;
                });

                $wrap.appendChild($img);
                $wrap.appendChild($name);
                $wrap.appendChild($captionInput);
                $wrap.appendChild($badge);
                $thumbs.appendChild($wrap);
            });
        }

        function clearHighlights() {
            $thumbs.querySelectorAll('.gu-thumb').forEach(function (el) {
                el.style.outline = '';
                el.style.outlineOffset = '';
            });
        }

        function setThumbState(i, state) {
            const $wrap = $thumbs.querySelector('[data-idx="' + i + '"]');
            if (!$wrap) return;
            const $badge = $wrap.querySelector('.gu-badge');
            if (state === 'uploading') {
                $wrap.style.opacity = '0.4';
            } else if (state === 'done') {
                $wrap.style.opacity = '1';
                if ($badge) {
                    $badge.style.display = 'flex';
                    $badge.style.background = '#4caf50';
                    $badge.textContent = '✓';
                }
            } else if (state === 'error') {
                $wrap.style.opacity = '1';
                if ($badge) {
                    $badge.style.display = 'flex';
                    $badge.style.background = '#f44336';
                    $badge.textContent = '✕';
                }
            }
        }

        // --- Upload via Bear's own endpoint ---

        function uploadFile(file) {
            return new Promise(function (resolve, reject) {
                const formData = new FormData();
                formData.append('file', file);
                formData.append('csrfmiddlewaretoken', csrfToken());

                const xhr = new XMLHttpRequest();
                xhr.open('POST', uploadUrl, true);
                xhr.setRequestHeader('X-CSRFToken', csrfToken());

                pendingXhrs.push(xhr);

                xhr.onload = function () {
                    pendingXhrs = pendingXhrs.filter(function (x) { return x !== xhr; });
                    if (this.status === 200) {
                        try {
                            resolve(JSON.parse(this.responseText));
                        } catch (e) {
                            reject(new Error('Invalid response'));
                        }
                    } else {
                        reject(new Error('HTTP ' + this.status));
                    }
                };

                xhr.onerror = function () {
                    pendingXhrs = pendingXhrs.filter(function (x) { return x !== xhr; });
                    reject(new Error('Network error'));
                };

                xhr.onabort = function () {
                    pendingXhrs = pendingXhrs.filter(function (x) { return x !== xhr; });
                    reject(new Error('Aborted'));
                };

                xhr.send(formData);
            });
        }

        // --- Build markdown for one image ---
        // Caption present: alt = caption (escaped), wrapped in figure/figcaption
        // Caption absent:  alt = filename, plain markdown

        function buildMarkdown(url, imageName, caption) {
            if (caption) {
                const safe = escapeHtml(caption);
                return '<figure>\n\n'
                    + '![' + safe + '](' + url + ')\n\n'
                    + '<figcaption>' + safe + '</figcaption>\n'
                    + '</figure>';
            }
            return '![' + imageName + '](' + url + ')';
        }

        // --- Upload all ---

        $btn.addEventListener('click', async function () {
            if (!files.length || uploading) return;

            uploading = true;
            $btn.disabled = true;
            $fileInput.disabled = true;
            $closeBtn.disabled = true;
            $thumbs.classList.add('gu-uploading');

            const pos = savedCursorPos;
            const initialBodyLength = $body.value.length;

            try {
                // UUID-based placeholders guarantee uniqueness regardless of filename
                const placeholders = files.map(function () {
                    return '![uploading-' + generateId() + ']()\n';
                });

                $body.value = $body.value.slice(0, pos)
                    + placeholders.join('')
                    + $body.value.slice(pos);

                let done = 0;
                let failed = 0;
                const successfulMarkdown = [];

                for (let i = 0; i < files.length; i++) {
                    // Exit cleanly if modal was closed between uploads
                    if (!uploading) break;

                    $status.textContent = 'Uploading ' + (i + 1) + ' of ' + files.length + '…';
                    setThumbState(i, 'uploading');

                    const file = files[i];
                    const placeholder = placeholders[i];
                    const imageName = file.name.replace(/\.[^/.]+$/, '');
                    const caption = captions[i] ? captions[i].trim() : '';

                    try {
                        const urls = await uploadFile(file);
                        const replacement = urls.map(function (url) {
                            return buildMarkdown(url, imageName, caption);
                        }).join('\n');
                        $body.value = $body.value.replace(placeholder, replacement + '\n');
                        successfulMarkdown.push(replacement);
                        done++;
                        setThumbState(i, 'done');
                    } catch (e) {
                        if (e.message === 'Aborted') {
                            placeholders.forEach(function (ph) {
                                $body.value = $body.value.replace(ph, '');
                            });
                            $body.dispatchEvent(new Event('input', { bubbles: true }));
                            break;
                        }
                        $body.value = $body.value.replace(
                            placeholder,
                            '![Upload of ' + file.name + ' failed]\n'
                        );
                        failed++;
                        setThumbState(i, 'error');
                    }
                }

                if ($wrapCheck.checked && done > 0) {
                    const current = $body.value;
                    const suffixLen = initialBodyLength - pos;
                    const blockEnd = current.length - suffixLen;
                    const insertedBlock = current.slice(pos, blockEnd);

                    if (insertedBlock.trim()) {
                        let finalBlock;

                        if (failed === 0) {
                            // Clean run - wrap everything
                            finalBlock = '<div class="bearming-gallery">\n\n'
                                + insertedBlock.trim()
                                + '\n\n</div>\n\n';
                        } else {
                            // Mixed run - only successful items go inside the gallery,
                            // error messages are placed below it
                            const galleryContent = successfulMarkdown.join('\n\n');
                            const errors = (insertedBlock.match(/!\[Upload of .+ failed\]\n?/g) || []).join('');
                            finalBlock = '<div class="bearming-gallery">\n\n'
                                + galleryContent
                                + '\n\n</div>\n\n'
                                + errors;
                        }

                        $body.value = current.slice(0, pos) + finalBlock + current.slice(blockEnd);
                    }
                }

                $body.dispatchEvent(new Event('input', { bubbles: true }));

                $status.textContent = failed
                    ? 'Done. ' + done + ' uploaded, ' + failed + ' failed.'
                    : 'Done. ' + done + ' photo' + (done > 1 ? 's' : '') + ' uploaded.';

                if (failed === 0) setTimeout(closeModal, 1200);

            } catch (e) {
                $status.textContent = 'Something went wrong. Please try again.';
            } finally {
                uploading = false;
                $btn.disabled = false;
                $fileInput.disabled = false;
                $fileInput.value = '';
                $closeBtn.disabled = false;
                $thumbs.classList.remove('gu-uploading');
            }
        });
    });
})();
</script>

Happy blogging and shooting!