Robert Birming

Gallery upload

A dashboard plugin for uploading, arranging, and captioning photos as a gallery, straight from the post editor.

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.

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 caption your photos. I've tried to make it feel as native to Bear as possible.

Tick "Mark as gallery" and each photo gets a #gallery marker, so it works with the photo gallery styles and lightbox. Captions then become the photo's alt text, which the lightbox shows under each photo. Leave it unticked to insert the photos one by one, with captions underneath.

How to use

  1. Copy the script below.
  2. Go to Customise dashboard.
  3. Paste the script under "Dashboard footer content" and save.
<script>
/* Gallery upload | 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('Mark as gallery (adds #gallery to each photo)'));

        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 ---
        // Gallery: the caption becomes the alt text, which the lightbox shows
        // Single photos: a caption wraps the image in figure/figcaption

        function buildMarkdown(url, imageName, caption, gallery) {
            if (gallery) {
                const alt = (caption || imageName).replace(/[\[\]]/g, '');
                return '![' + alt + '](' + url + '#gallery)';
            }
            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;

            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;

                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, $wrapCheck.checked);
                        }).join('\n');
                        $body.value = $body.value.replace(placeholder, replacement + '\n');
                        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');
                    }
                }

                $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>

Notes

Harry's reaction to the plugin

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

Happy blogging and shooting!

Want more? Check out the full Bear Blog library.