Skip to content

Instantly share code, notes, and snippets.

@duducp
Last active July 31, 2025 13:58
Show Gist options
  • Select an option

  • Save duducp/0c5b32f7936b924b39c10e5983de1199 to your computer and use it in GitHub Desktop.

Select an option

Save duducp/0c5b32f7936b924b39c10e5983de1199 to your computer and use it in GitHub Desktop.
Django 5 #django

The script below shows how to add the collapse button to the django admin filters.

In the static directory, which is configured in the django settings STATICFILES_DIRS, create the file changelist_filter_collapse.js inside /admin/js. For example, if your STATICFILES_DIRS is mapped to the statics directory, the file would look like this /statics/admin.js/changelist_filter_collapse.js.

// changelist_filter_collapse.js
// The original code for the snippet is here: https://gist.github.com/duducp/0c5b32f7936b924b39c10e5983de1199#file-changelist-filter-collapse-md

// Wait for the DOM to be fully loaded before running the script
document.addEventListener('DOMContentLoaded', function() {
    // Get the filter navigation element by its ID
    const nav = document.getElementById('changelist-filter');
    if (!nav) return; // Exit if the filter navigation is not found

    // Get the main content element and set its position to relative
    const main = document.getElementById('main');
    if (main) {
        main.style.position = 'relative';
        main.style.overflowX = 'clip'; // Prevent horizontal overflow
    }

    // Get the content element for padding adjustment
    const content = document.getElementById('content');
    // Get the object-tools element for padding adjustment
    const objectTools = document.querySelector('.object-tools');

    // Create the toggle button and append it to the #main div
    const btn = createToggleButton();
    if (main) {
        main.appendChild(btn);
    } else {
        nav.parentNode.insertBefore(btn, nav.nextSibling);
    }

    // Add click event to toggle the visibility of the filter navigation
    btn.addEventListener('click', function () {
        if (nav.style.display !== 'none') {
            // Hide the filter navigation and update button text
            nav.style.display = 'none';
            btn.textContent = '«';
            // Save collapsed state in localStorage
            localStorage.setItem('django.admin.navSidebarRightIsOpen', 'true');
            // Adds 40px padding-right to #content
            if (content) content.style.paddingRight = '40px';
            if (objectTools) objectTools.style.paddingRight = '23px';
        } else {
            // Show the filter navigation and update button text
            nav.style.display = '';
            btn.textContent = '»';
            // Save expanded state in localStorage
            localStorage.setItem('django.admin.navSidebarRightIsOpen', 'false');
            // Remove padding-right do #content
            if (content) content.style.paddingRight = '23px';
            if (objectTools) objectTools.style.paddingRight = '40px';
        }
    });

    // On page load, check localStorage and hide filter navigation if needed
    if (localStorage.getItem('django.admin.navSidebarRightIsOpen') === 'true') {
        nav.style.display = 'none';
        btn.textContent = '«';
        if (content) content.style.paddingRight = '40px';
        if (objectTools) objectTools.style.paddingRight = '23px';
    } else {
        if (content) content.style.paddingRight = '23px';
        if (objectTools) objectTools.style.paddingRight = '40px';
    }
});

// Function to create and style the toggle button
function createToggleButton() {
    const btn = document.createElement('button');
    btn.className = 'toggle-filter-btn';
    btn.id = 'toggle-filter-btn';
    btn.type = 'button';
    btn.textContent = '»';

    // Define button styles for positioning and appearance
    const styles = {
        position: 'sticky',
        right: '0',
        top: '0',
        width: '23px',
        height: '100vh',
        maxHeight: '100vh',
        backgroundColor: 'var(--body-bg)',
        color: 'var(--link-fg)',
        fontSize: '1.25rem',
        cursor: 'pointer',
        border: '0',
        borderLeft: '1px solid var(--hairline-color)',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
        zIndex: '10'
    };

    // Apply the defined styles to the button
    Object.assign(btn.style, styles);

    // Add hover effect to change background color
    btn.addEventListener('mouseover', function () {
        btn.style.backgroundColor = 'var(--darkened-bg)';
    });
    btn.addEventListener('mouseout', function () {
        btn.style.backgroundColor = styles.backgroundColor;
    });
    return btn;
}

To inject a JS file into your Django admin, you have these main options:

  1. By ModelAdmin Media class Add a Media class to your ModelAdmin and specify the JS file path:
class MyModelAdmin(admin.ModelAdmin):
    class Media:
        js = ('admin/js/changelist_filter_collapse.js',)

This loads the JS only for that admin.

  1. Globally by admin templates Override admin/base_site.html or admin/change_list.html and add a <script> tag:
{% extends 'admin/base_site.html' %}
{% load static %}

{% block extrahead %}
    {{ block.super }}
    <script src="{% static 'admin/js/changelist_filter_collapse.js' %}"></script>
{% endblock %}

This loads the JS for all admin pages. You must create the HTML file within the templates directory configured in Django's settings. In this case, it would look like this: templates/admin

The script below shows how to add the following features to the columns of the listview table.

  • Hide one or more columns
  • Freeze one or more columns
  • Reorder the columns

All changes made are saved in the user's session, that is, when a new session is created, the changes will be lost.

In the static directory, which is configured in the django settings STATICFILES_DIRS, create the file changelist_organizer_columns.js inside /admin/js. For example, if your STATICFILES_DIRS is mapped to the statics directory, the file would look like this /statics/admin.js/changelist_organizer_columns.js.

// changelist_organizer_columns.js
// The original code for the snippet is here: https://gist.github.com/duducp/0c5b32f7936b924b39c10e5983de1199#file-changelist-organizer-columns-md

// Wait for the DOM to be fully loaded before running the script
document.addEventListener('DOMContentLoaded', function() {
    const table = document.getElementById('result_list');
    if (!table) return;

    // --- SessionStorage Key ---
    const STORAGE_KEY = 'changelist_columns_' + location.pathname;

    // --- Helpers for default state (reset only) ---
    let defaultState = null;
    function getDefaultState() {
        const ths = Array.from(table.querySelectorAll('thead th'));
        return ths.map((th, idx) => ({
            idx,
            text: th.textContent.trim(),
            pinned: th.classList.contains('pinned-column'),
            visible: th.style.display !== 'none'
        }));
    }
    function saveDefaultState() {
        if (!defaultState) {
            defaultState = getDefaultState();
        }
    }
    function restoreState(state) {
        if (!state) return;
        // Map current ths and cells by their text content
        const ths = Array.from(table.querySelectorAll('thead th'));
        const tr = table.querySelector('thead tr');
        const thByText = {};
        ths.forEach(th => {
            thByText[th.textContent.trim()] = th;
        });

        // Remove all ths
        ths.forEach(th => tr.removeChild(th));
        // Add in new order by matching text
        state.forEach(col => {
            if (thByText[col.text]) tr.appendChild(thByText[col.text]);
        });

        // Reorder tbody
        const rows = table.querySelectorAll('tbody tr');
        rows.forEach(row => {
            const cells = Array.from(row.children);
            // Build a map from header text to cell
            const cellByText = {};
            cells.forEach((cell, i) => {
                if (ths[i]) {
                    cellByText[ths[i].textContent.trim()] = cell;
                }
            });
            // Remove all cells
            while (row.firstChild) row.removeChild(row.firstChild);
            state.forEach(col => {
                if (cellByText[col.text]) row.appendChild(cellByText[col.text]);
            });
        });

        // Set visibility and pin
        const newThs = Array.from(table.querySelectorAll('thead th'));
        state.forEach((col, i) => {
            const th = newThs[i];
            if (!th) return;
            if (col.visible) showColumn(i);
            else hideColumn(i);
            if (col.pinned) pinColumn(i);
            else unpinColumn(i);
        });

        // After restoring, save to sessionStorage
        saveTableState();
    }

    // --- Table State Persistence ---
    function getTableState() {
        const ths = Array.from(table.querySelectorAll('thead th'));
        return ths.map((th, idx) => ({
            idx,
            text: th.textContent.trim(),
            pinned: th.classList.contains('pinned-column'),
            visible: th.style.display !== 'none'
        }));
    }
    function saveTableState() {
        try {
            sessionStorage.setItem(STORAGE_KEY, JSON.stringify(getTableState()));
        } catch (e) {}
    }
    function loadTableState() {
        try {
            const data = sessionStorage.getItem(STORAGE_KEY);
            if (data) return JSON.parse(data);
        } catch (e) {}
        return null;
    }
    function clearTableState() {
        try {
            sessionStorage.removeItem(STORAGE_KEY);
        } catch (e) {}
    }

    // --- Hide/Show Column Logic ---
    function hideColumn(idx) {
        const headerCells = table.querySelectorAll('thead th');
        if (headerCells[idx]) headerCells[idx].style.display = 'none';
        const rows = table.querySelectorAll('tbody tr');
        rows.forEach(row => {
            if (row.children[idx]) row.children[idx].style.display = 'none';
        });
        saveTableState();
    }

    function showColumn(idx) {
        const headerCells = table.querySelectorAll('thead th');
        if (headerCells[idx]) headerCells[idx].style.display = '';
        const rows = table.querySelectorAll('tbody tr');
        rows.forEach(row => {
            if (row.children[idx]) row.children[idx].style.display = '';
        });
        saveTableState();
    }

    // --- Add Hide Button and Pin Button to Columns ---
    function addHideButtons() {
        const headerCells = table.querySelectorAll('thead th');
        headerCells.forEach((th, idx) => {
            // Do not add hide/pin button to the first column if it has class 'action-checkbox-column'
            if (idx === 0 && th.classList.contains('action-checkbox-column')) return;
            if (th.querySelector('.hide-col-btn')) return;

            th.style.whiteSpace = 'nowrap';
            th.style.verticalAlign = 'middle';

            // Wrap existing content in a span if not already
            let contentWrap = th.querySelector('.th-content-wrap');
            if (!contentWrap) {
                contentWrap = document.createElement('span');
                contentWrap.className = 'th-content-wrap';
                contentWrap.style.display = 'flex';
                contentWrap.style.alignItems = 'center';
                contentWrap.style.justifyContent = 'space-between';
                contentWrap.style.width = '100%';
                while (th.firstChild) {
                    contentWrap.appendChild(th.firstChild);
                }
                th.appendChild(contentWrap);
            } else {
                contentWrap.style.display = 'flex';
                contentWrap.style.alignItems = 'center';
                contentWrap.style.justifyContent = 'space-between';
                contentWrap.style.width = '100%';
            }

            // Hide button styled like Django sort options
            const hideBtn = document.createElement('a');
            hideBtn.href = '#';
            hideBtn.title = 'Hide column';
            hideBtn.className = 'hide-col-btn sorthide';
            hideBtn.style.cssText = `
                margin-left: 6px;
                font-size: 13px;
                color: var(--primary, #ba2121);
                text-decoration: none;
                vertical-align: middle;
                display: flex;
                align-items: center;
                justify-content: center;
                height: 20px;
                width: 20px;
                border-radius: 2px;
            `;
            hideBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" style="vertical-align:middle;" xmlns="http://www.w3.org/2000/svg"><rect x="2" y="5" width="8" height="2" rx="1" fill="currentColor"/></svg>';
            hideBtn.addEventListener('click', function(e) {
                e.preventDefault();
                e.stopPropagation();
                const currentThs = Array.from(table.querySelectorAll('thead th'));
                const realIdx = currentThs.indexOf(th);
                hideColumn(realIdx);
            });

            // Pin button styled like Django sort options
            const pinBtn = document.createElement('a');
            pinBtn.href = '#';
            pinBtn.title = th.classList.contains('pinned-column') ? 'Unpin column' : 'Pin column';
            pinBtn.className = 'pin-col-btn sortpin';
            pinBtn.style.cssText = `
                margin-left: 4px;
                font-size: 13px;
                color: var(--primary, #3273dc);
                text-decoration: none;
                vertical-align: middle;
                display: flex;
                align-items: center;
                justify-content: center;
                height: 20px;
                width: 20px;
                border-radius: 2px;
            `;
            pinBtn.innerHTML = th.classList.contains('pinned-column')
                ? '<svg width="12" height="12" viewBox="0 0 12 12" style="vertical-align:middle;" xmlns="http://www.w3.org/2000/svg"><path d="M3 9l6-6M9 3v6H3" stroke="currentColor" stroke-width="2" fill="none"/></svg>'
                : '<svg width="12" height="12" viewBox="0 0 12 12" style="vertical-align:middle;" xmlns="http://www.w3.org/2000/svg"><rect x="5" y="2" width="2" height="8" rx="1" fill="currentColor"/><rect x="2" y="5" width="8" height="2" rx="1" fill="currentColor"/></svg>';
            pinBtn.addEventListener('click', function(e) {
                e.preventDefault();
                e.stopPropagation();
                const currentThs = Array.from(table.querySelectorAll('thead th'));
                const realIdx = currentThs.indexOf(th);
                if (th.classList.contains('pinned-column')) {
                    unpinColumn(realIdx);
                    updatePinBtnIcon(pinBtn, false);
                } else {
                    pinColumn(realIdx);
                    updatePinBtnIcon(pinBtn, true);
                }
            });

            // Remove any existing hide/pin button in the wrapper before appending
            const oldHideBtn = contentWrap.querySelector('.hide-col-btn');
            if (oldHideBtn) oldHideBtn.remove();
            const oldPinBtn = contentWrap.querySelector('.pin-col-btn');
            if (oldPinBtn) oldPinBtn.remove();

            // Place the hide and pin buttons at the end (right) of the flex container
            contentWrap.appendChild(hideBtn);
            contentWrap.appendChild(pinBtn);
        });
    }

    // Helper to update the pin icon and title
    function updatePinBtnIcon(pinBtn, pinned) {
        if (!pinBtn) return;
        if (pinned) {
            pinBtn.title = 'Unpin column';
            pinBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" style="vertical-align:middle;" xmlns="http://www.w3.org/2000/svg"><path d="M3 9l6-6M9 3v6H3" stroke="currentColor" stroke-width="2" fill="none"/></svg>';
        } else {
            pinBtn.title = 'Pin column';
            pinBtn.innerHTML = '<svg width="12" height="12" viewBox="0 0 12 12" style="vertical-align:middle;" xmlns="http://www.w3.org/2000/svg"><rect x="5" y="2" width="2" height="8" rx="1" fill="currentColor"/><rect x="2" y="5" width="8" height="2" rx="1" fill="currentColor"/></svg>';
        }
    }

    // --- Hide/Show Columns Popup ---
    function createShowColumnsPopup() {
        if (document.getElementById('show-columns-popup')) return;

        // Create overlay
        const overlay = document.createElement('div');
        overlay.id = 'show-columns-popup-overlay';
        overlay.style.cssText = `
            position: fixed;
            top: 0; left: 0; right: 0; bottom: 0;
            background: rgba(0,0,0,0.3);
            z-index: 9998;
        `;

        const ths = table.querySelectorAll('thead th');
        const popup = document.createElement('div');
        popup.id = 'show-columns-popup';
        popup.style.cssText = `
            position: fixed;
            top: 80px;
            left: 50%;
            transform: translateX(-50%);
            background: var(--body-bg, #fff);
            color: var(--body-fg, #333);
            border: 1px solid var(--border-color, #ccc);
            border-radius: 6px;
            box-shadow: 0 4px 24px rgba(0,0,0,0.15);
            z-index: 9999;
            padding: 18px 24px 18px 24px;
            min-width: 350px;
            max-width: 90vw;
        `;

        const title = document.createElement('div');
        title.textContent = 'MANAGE COLUMNS';
        title.style.cssText = 'font-weight: bold; margin-bottom: 12px; font-size: 16px;';
        popup.appendChild(title);

        // --- Info message ---
        const infoMsg = document.createElement('div');
        infoMsg.textContent = 'Changes are saved only for this session.';
        infoMsg.style.cssText = 'font-size: 13px; color: var(--body-quiet-color, #888); margin-bottom: 10px;';
        popup.appendChild(infoMsg);

        const closeBtn = document.createElement('button');
        closeBtn.type = 'button';
        closeBtn.innerHTML = '&times;';
        closeBtn.style.cssText = `
            position: absolute;
            top: 8px;
            right: 12px;
            background: none;
            border: none;
            font-size: 22px;
            color: var(--body-fg, #333);
            cursor: pointer;
        `;
        closeBtn.onclick = () => {
            popup.remove();
            overlay.remove();
        };
        popup.appendChild(closeBtn);

        // Container for list and ordering (now as a table)
        const tableContainer = document.createElement('div');
        tableContainer.style.cssText = 'clear: both; max-height: 50vh; overflow-y: auto; margin-top: 12px;';

        const columnsTable = document.createElement('table');
        columnsTable.style.cssText = `
            width: 100%;
            border-collapse: collapse;
            background: transparent;
        `;

        // Build list of columns for show/hide and ordering
        const columnItems = [];
        // Add pin checkbox
        ths.forEach((th, idx) => {
            if (idx === 0 && th.classList.contains('action-checkbox-column')) return;

            const tr = document.createElement('tr');
            tr.className = 'column-popup-item';
            tr.setAttribute('data-col-idx', idx);
            tr.style.cursor = 'grab';

            // Drag handle
            const dragTd = document.createElement('td');
            dragTd.style.cssText = 'width:28px;padding:4px 0 4px 0;text-align:center;';
            const dragHandle = document.createElement('span');
            dragHandle.innerHTML = `<svg width="16" height="16" viewBox="0 0 18 18" style="opacity:0.7;" xmlns="http://www.w3.org/2000/svg">
                <circle cx="5" cy="5" r="1.5" fill="currentColor"/>
                <circle cx="5" cy="9" r="1.5" fill="currentColor"/>
                <circle cx="5" cy="13" r="1.5" fill="currentColor"/>
                <circle cx="13" cy="5" r="1.5" fill="currentColor"/>
                <circle cx="13" cy="9" r="1.5" fill="currentColor"/>
                <circle cx="13" cy="13" r="1.5" fill="currentColor"/>
            </svg>`;
            dragHandle.style.cssText = 'cursor: grab; color: var(--primary, #3273dc);';
            dragHandle.className = 'popup-drag-handle';
            dragHandle.title = 'Order column';
            dragTd.appendChild(dragHandle);

            // Show/Hide Checkbox
            const checkboxTd = document.createElement('td');
            checkboxTd.style.cssText = 'width:32px;padding:4px 0 4px 0;text-align:center;';
            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.checked = th.style.display !== 'none';
            checkbox.title = 'Show/Hide column';
            checkbox.style.marginRight = '0';
            checkboxTd.appendChild(checkbox);

            // Pin checkbox
            const pinTd = document.createElement('td');
            pinTd.style.cssText = 'width:32px;padding:4px 0 4px 0;text-align:center;';
            const pinCheckbox = document.createElement('input');
            pinCheckbox.type = 'checkbox';
            pinCheckbox.checked = th.classList.contains('pinned-column');
            pinCheckbox.title = 'Pin column';
            pinTd.appendChild(pinCheckbox);

            pinCheckbox.addEventListener('change', function() {
                if (pinCheckbox.checked) {
                    pinColumn(idx);
                } else {
                    unpinColumn(idx);
                }
            });

            // Label
            const labelTd = document.createElement('td');
            labelTd.style.cssText = 'padding:4px 0 4px 0;';
            let labelText = th.textContent || `Column ${idx+1}`;
            const label = document.createElement('span');
            label.textContent = labelText.trim();
            label.style.flex = '1';
            labelTd.appendChild(label);

            checkbox.addEventListener('change', function() {
                if (checkbox.checked) {
                    showColumn(idx);
                } else {
                    hideColumn(idx);
                }
            });

            tr.appendChild(dragTd);
            tr.appendChild(checkboxTd);
            tr.appendChild(pinTd);
            tr.appendChild(labelTd);

            columnItems.push(tr);
            columnsTable.appendChild(tr);
        });

        // --- Update columnsTable header to include pin column ---
        if (!columnsTable.querySelector('thead')) {
            const thead = document.createElement('thead');
            const headerRow = document.createElement('tr');
            headerRow.innerHTML = `
                <th>Order</th>
                <th>Show</th>
                <th>Pin</th>
                <th>Column</th>
            `;
            thead.appendChild(headerRow);
            columnsTable.appendChild(thead);
        }

        // --- Visual indicator for drop position ---
        let dropIndicator = document.createElement('tr');
        dropIndicator.className = 'popup-drop-indicator';
        dropIndicator.style.pointerEvents = 'none';
        dropIndicator.innerHTML = `<td colspan="4" style="padding:0;height:0;">
            <div style="height:0;border-top:2px solid var(--primary,#3273dc);margin:0 0 0 0;"></div>
        </td>`;

        // Drag and drop logic for ordering columns in popup
        let dragSrcItem = null;
        let lastDropTarget = null;
        let lastDropBefore = true;

        columnItems.forEach(item => {
            item.draggable = true;
            item.addEventListener('dragstart', function(e) {
                dragSrcItem = item;
                item.style.opacity = '0.5';
                e.dataTransfer.effectAllowed = 'move';
                // Remove any existing indicator
                if (dropIndicator.parentNode) dropIndicator.parentNode.removeChild(dropIndicator);
            });
            item.addEventListener('dragend', function(e) {
                item.style.opacity = '';
                dragSrcItem = null;
                if (dropIndicator.parentNode) dropIndicator.parentNode.removeChild(dropIndicator);
            });
            item.addEventListener('dragover', function(e) {
                e.preventDefault();
                e.dataTransfer.dropEffect = 'move';
                // Determine if cursor is in top or bottom half
                const rect = item.getBoundingClientRect();
                const offset = e.clientY - rect.top;
                const before = offset < rect.height / 2;
                // Remove indicator if already present elsewhere
                if (dropIndicator.parentNode) dropIndicator.parentNode.removeChild(dropIndicator);
                if (before) {
                    columnsTable.insertBefore(dropIndicator, item);
                } else {
                    if (item.nextSibling) {
                        columnsTable.insertBefore(dropIndicator, item.nextSibling);
                    } else {
                        columnsTable.appendChild(dropIndicator);
                    }
                }
                lastDropTarget = item;
                lastDropBefore = before;
            });
            item.addEventListener('dragleave', function(e) {
                // Remove indicator if leaving the row and not moving to indicator itself
                if (dropIndicator.parentNode && !columnsTable.contains(e.relatedTarget)) {
                    dropIndicator.parentNode.removeChild(dropIndicator);
                }
            });
            item.addEventListener('drop', function(e) {
                e.preventDefault();
                if (dragSrcItem && dragSrcItem !== item) {
                    if (dropIndicator.parentNode) dropIndicator.parentNode.removeChild(dropIndicator);
                    if (lastDropBefore) {
                        columnsTable.insertBefore(dragSrcItem, item);
                    } else {
                        if (item.nextSibling) {
                            columnsTable.insertBefore(dragSrcItem, item.nextSibling);
                        } else {
                            columnsTable.appendChild(dragSrcItem);
                        }
                    }
                    updateTableColumnOrder();
                }
            });
        });

        columnsTable.addEventListener('drop', function(e) {
            // Handle drop at the end of the table
            if (dragSrcItem && dropIndicator.parentNode) {
                columnsTable.appendChild(dragSrcItem);
                dropIndicator.parentNode.removeChild(dropIndicator);
                updateTableColumnOrder();
            }
        });

        // Update table column order based on popup table
        function updateTableColumnOrder() {
            // Get new order and pin states from popup (use the DOM order of .column-popup-item)
            const newOrder = [];
            const newVisibility = [];
            const pinStates = [];
            columnsTable.querySelectorAll('.column-popup-item').forEach(item => {
                const label = item.querySelector('td:last-child span');
                const colText = label ? label.textContent.trim() : '';
                newOrder.push(colText);
                const checkbox = item.querySelector('input[type="checkbox"]');
                newVisibility.push(checkbox && checkbox.checked);
                const pinCheckbox = item.querySelectorAll('input[type="checkbox"]')[1];
                pinStates.push(pinCheckbox && pinCheckbox.checked);
            });

            // Get all ths and cells before any DOM manipulation
            const thead = table.querySelector('thead');
            const tr = thead.querySelector('tr');
            const thsArr = Array.from(tr.children);
            let startIdx = 0;
            if (thsArr[0] && thsArr[0].classList.contains('action-checkbox-column')) startIdx = 1;

            // Build a map of th label text to th element (before any DOM changes)
            const thMap = {};
            thsArr.forEach((th, i) => {
                if (i === 0 && startIdx === 1) return; // skip action-checkbox-column
                thMap[th.textContent.trim()] = th;
            });

            // Reorder thead
            while (tr.firstChild) tr.removeChild(tr.firstChild);
            if (startIdx === 1) tr.appendChild(thsArr[0]);
            newOrder.forEach(colText => {
                if (thMap[colText]) tr.appendChild(thMap[colText]);
            });

            // Reorder tbody rows
            const rows = table.querySelectorAll('tbody tr');
            rows.forEach(row => {
                const cells = Array.from(row.children);
                const cellMap = {};
                cells.forEach((cell, i) => {
                    if (i === 0 && startIdx === 1) return; // skip action-checkbox-column
                    const th = thsArr[i];
                    if (th) cellMap[th.textContent.trim()] = cell;
                });
                const frag = document.createDocumentFragment();
                if (startIdx === 1) frag.appendChild(cells[0]);
                newOrder.forEach(colText => {
                    if (cellMap[colText]) frag.appendChild(cellMap[colText]);
                });
                row.innerHTML = '';
                row.appendChild(frag);
            });

            // Set visibility and pin
            const newThsArr = Array.from(tr.children);
            newThsArr.forEach((th, i) => {
                // Adjust index for show/hide/pin to skip action-checkbox-column
                let colIdx = i;
                if (startIdx === 1) colIdx = i + 1;
                if (i < newVisibility.length) {
                    if (newVisibility[i]) showColumn(colIdx);
                    else hideColumn(colIdx);
                }
            });
            // Pin columns according to the new order and pinStates
            newThsArr.forEach((th, i) => {
                let colIdx = i;
                if (startIdx === 1) colIdx = i + 1;
                const pinBtn = th.querySelector('.pin-col-btn');
                if (i < pinStates.length) {
                    if (pinStates[i]) {
                        pinColumn(colIdx);
                        updatePinBtnIcon(pinBtn, true);
                    } else {
                        unpinColumn(colIdx);
                        updatePinBtnIcon(pinBtn, false);
                    }
                }
            });
        }

        // In the popup, after drag/drop or checkbox change, always update pin icons in the table
        columnsTable.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
            checkbox.addEventListener('change', function() {
                updateTableColumnOrder();
                // Update pin icons after any change
                setTimeout(() => {
                    const ths = table.querySelectorAll('thead th');
                    ths.forEach(th => {
                        const pinBtn = th.querySelector('.pin-col-btn');
                        if (pinBtn) {
                            updatePinBtnIcon(pinBtn, th.classList.contains('pinned-column'));
                        }
                    });
                }, 10);
            });
        });

        tableContainer.appendChild(columnsTable);

        // --- Footer with Reset Button ---
        const footer = document.createElement('div');
        footer.style.cssText = 'margin-top: 18px; display: flex; justify-content: flex-end; gap: 8px;';

        // Reset Button
        const resetBtn = document.createElement('button');
        resetBtn.type = 'button';
        resetBtn.textContent = 'RESET';
        resetBtn.title = 'Reset to default columns';
        resetBtn.style.cssText = `
            background: var(--button-bg);
            color: var(--button-fg);
            border: none;
            border-radius: 4px;
            padding: 4px 12px;
            cursor: pointer;
            transition: background 0.15s;
        `;
        resetBtn.addEventListener('mouseenter', function() {
            resetBtn.style.background = 'var(--button-hover-bg)';
        });
        resetBtn.addEventListener('mouseleave', function() {
            resetBtn.style.background = 'var(--button-bg)';
        });
        resetBtn.onclick = function() {
            if (window.confirm('Are you sure you want to reset columns to default configuration?')) {
                clearTableState();
                if (defaultState) {
                    restoreState(defaultState);
                    // Atualiza os ícones dos botões de pin após reset
                    setTimeout(() => {
                        const ths = table.querySelectorAll('thead th');
                        ths.forEach(th => {
                            const pinBtn = th.querySelector('.pin-col-btn');
                            if (pinBtn) {
                                updatePinBtnIcon(pinBtn, th.classList.contains('pinned-column'));
                            }
                        });
                    }, 10);
                    // Rebuild popup UI to reflect default
                    popup.remove();
                    overlay.remove();
                    setTimeout(createShowColumnsPopup, 50);
                } else {
                    window.location.reload();
                }
            }
        };
        footer.appendChild(resetBtn);

        popup.appendChild(tableContainer);
        popup.appendChild(footer);

        document.body.appendChild(overlay);
        document.body.appendChild(popup);

        // Focus on first checkbox
        setTimeout(() => {
            const firstCheckbox = popup.querySelector('input[type="checkbox"]');
            if (firstCheckbox) firstCheckbox.focus();
        }, 100);
    }

    // --- Add Show/Hide Columns Button to object-tools ---
    function addShowColumnsButton() {
        const objectTools = document.querySelector('.object-tools');
        if (!objectTools || objectTools.querySelector('.show-columns-btn')) return;

        const btn = document.createElement('li');
        btn.innerHTML = `<a href="#" class="show-columns-btn" style="display:flex;align-items:center;gap:6px;">
            Columns
            <svg width="16" height="16" viewBox="0 0 18 18" style="opacity:0.7;" xmlns="http://www.w3.org/2000/svg">
                <rect x="2" y="4" width="14" height="2" rx="1" fill="currentColor"/>
                <rect x="2" y="8" width="14" height="2" rx="1" fill="currentColor"/>
                <rect x="2" y="12" width="14" height="2" rx="1" fill="currentColor"/>
            </svg>
        </a>`;
        btn.querySelector('a').onclick = function(e) {
            e.preventDefault();
            createShowColumnsPopup();
        };
        objectTools.appendChild(btn);
    }

    // --- Pin/Unpin Column Logic ---
    function pinColumn(idx) {
        const ths = table.querySelectorAll('thead th');
        if (!ths[idx]) return;
        ths[idx].classList.add('pinned-column');
        ths[idx].style.position = 'sticky';
        ths[idx].style.left = getPinnedLeft(idx) + 'px';
        ths[idx].style.zIndex = '2';
        ths[idx].style.background = 'var(--selected-row, #ffe)';
        // Pin all cells in this column
        const rows = table.querySelectorAll('tbody tr');
        rows.forEach(row => {
            if (row.children[idx]) {
                row.children[idx].classList.add('pinned-column');
                row.children[idx].style.position = 'sticky';
                row.children[idx].style.left = getPinnedLeft(idx) + 'px';
                row.children[idx].style.zIndex = '1';
                row.children[idx].style.background = 'var(--selected-row, #ffe)';
            }
        });
        saveTableState();
    }

    function unpinColumn(idx) {
        const ths = table.querySelectorAll('thead th');
        if (!ths[idx]) return;
        ths[idx].classList.remove('pinned-column');
        ths[idx].style.position = '';
        ths[idx].style.left = '';
        ths[idx].style.zIndex = '';
        ths[idx].style.background = '';
        // Unpin all cells in this column
        const rows = table.querySelectorAll('tbody tr');
        rows.forEach(row => {
            if (row.children[idx]) {
                row.children[idx].classList.remove('pinned-column');
                row.children[idx].style.position = '';
                row.children[idx].style.left = '';
                row.children[idx].style.zIndex = '';
                row.children[idx].style.background = '';
            }
        });
        saveTableState();
    }

    // Helper to calculate left offset for sticky columns
    function getPinnedLeft(idx) {
        const ths = table.querySelectorAll('thead th');
        let left = 0;
        for (let i = 0; i < ths.length; i++) {
            if (i === idx) break;
            // Only count visible and pinned columns before idx
            if (ths[i] && ths[i].style.display !== 'none' && ths[i].classList.contains('pinned-column')) {
                left += ths[i].offsetWidth;
            }
        }
        return left;
    }

    // --- Initial Setup ---
    addHideButtons();
    addShowColumnsButton();
    saveDefaultState();

    // --- Restore state from sessionStorage if present ---
    const savedState = loadTableState();
    if (savedState && Array.isArray(savedState)) {
        restoreState(savedState);
    }
});

To inject a JS file into your Django admin, you have these main options:

  1. By ModelAdmin Media class Add a Media class to your ModelAdmin and specify the JS file path:
class MyModelAdmin(admin.ModelAdmin):
    class Media:
        js = ('admin/js/changelist_organizer_columns.js',)

This loads the JS only for that admin.

  1. Globally by admin templates Override admin/base_site.html or admin/change_list.html and add a <script> tag:
{% extends 'admin/base_site.html' %}
{% load static %}

{% block extrahead %}
    {{ block.super }}
    <script src="{% static 'admin/js/changelist_organizer_columns.js' %}"></script>
{% endblock %}

This loads the JS for all admin pages. You must create the HTML file within the templates directory configured in Django's settings. In this case, it would look like this: templates/admin

The script below shows how to add resize functionality to listview table columns.

In the static directory, which is configured in the django settings STATICFILES_DIRS, create the file changelist_resizable_columns.js inside /admin/js. For example, if your STATICFILES_DIRS is mapped to the statics directory, the file would look like this /statics/admin.js/changelist_resizable_columns.js.

// changelist_resizable_columns.js
// The original code for the snippet is here: https://gist.github.com/duducp/0c5b32f7936b924b39c10e5983de1199#file-changelist-resizable-columns-md

// Wait for the DOM to be fully loaded before running the script
document.addEventListener('DOMContentLoaded', function() {
    const table = document.getElementById('result_list');
    if (!table) return;

    // --- Resize Column Logic ---
    function makeColumnsResizable() {
        const ths = table.querySelectorAll('thead th');
        ths.forEach((th, idx) => {
            // Do not add resizer to the first column if it has class 'action-checkbox-column'
            if (idx === 0 && th.classList.contains('action-checkbox-column')) return;
            if (th.querySelector('.col-resizer')) return;

            const resizer = document.createElement('div');
            resizer.className = 'col-resizer';
            resizer.style.cssText = `
                position: absolute;
                right: 0;
                top: 0;
                height: 100%;
                width: 8px;
                cursor: col-resize;
                user-select: none;
                z-index: 20;
            `;
            th.style.position = 'relative';

            let startX, startWidth;
            let guide = null;

            function showGuide() {
                // Remove any existing guide
                const oldGuide = table.parentElement.querySelector('.col-resize-guide');
                if (oldGuide) oldGuide.remove();

                if (guide) return;

                guide = document.createElement('div');
                guide.className = 'col-resize-guide';

                // Calculate the left position relative to the table container
                const tableRect = table.getBoundingClientRect();
                const thRect = th.getBoundingClientRect();
                // Clamp left to table width to avoid disappearing when columns are moved
                let left = thRect.right - tableRect.left - 1;
                if (left < 0) left = 0;
                if (left > table.offsetWidth) left = table.offsetWidth - 2;

                guide.style.position = 'absolute';
                guide.style.top = '0';
                guide.style.left = `${left}px`;
                guide.style.height = `${table.offsetHeight}px`;
                guide.style.width = '3px';
                guide.style.background = 'var(--primary, #3273dc)';
                guide.style.opacity = '0.7';
                guide.style.pointerEvents = 'none';
                guide.style.zIndex = '1000';
                guide.style.borderRadius = '2px';

                // Ensure parent is relative for absolute positioning
                const parent = table.parentElement;
                if (getComputedStyle(parent).position === 'static') {
                    parent.style.position = 'relative';
                }
                parent.appendChild(guide);
            }

            function hideGuide() {
                if (guide && guide.parentElement) {
                    guide.parentElement.removeChild(guide);
                }
                guide = null;
            }

            resizer.addEventListener('mouseenter', showGuide);
            resizer.addEventListener('mouseleave', hideGuide);

            resizer.addEventListener('mousedown', function(e) {
                e.preventDefault();
                startX = e.pageX;
                startWidth = th.offsetWidth;
                document.body.style.cursor = 'col-resize';

                function onMouseMove(e2) {
                    const newWidth = Math.max(30, startWidth + (e2.pageX - startX));
                    th.style.width = newWidth + 'px';
                    th.style.minWidth = newWidth + 'px';
                    th.style.maxWidth = newWidth + 'px';
                    // Set width for all cells in this column
                    table.querySelectorAll('tbody tr').forEach(row => {
                        if (row.children[idx]) {
                            row.children[idx].style.width = newWidth + 'px';
                            row.children[idx].style.minWidth = newWidth + 'px';
                            row.children[idx].style.maxWidth = newWidth + 'px';
                        }
                    });
                    // Move guide as column resizes
                    if (guide) {
                        const tableRect = table.getBoundingClientRect();
                        const thRect = th.getBoundingClientRect();
                        let left = thRect.right - tableRect.left - 1;
                        if (left < 0) left = 0;
                        if (left > table.offsetWidth) left = table.offsetWidth - 2;
                        guide.style.left = `${left}px`;
                    }
                }

                function onMouseUp() {
                    document.removeEventListener('mousemove', onMouseMove);
                    document.removeEventListener('mouseup', onMouseUp);
                    document.body.style.cursor = '';
                    hideGuide();
                }

                document.addEventListener('mousemove', onMouseMove);
                document.addEventListener('mouseup', onMouseUp);
                showGuide();
            });

            th.appendChild(resizer);
        });
    }

    // --- Styles for Resizer ---
    function injectResizeStyles() {
        if (document.getElementById('col-resizer-style')) return;
        const style = document.createElement('style');
        style.id = 'col-resizer-style';
        style.innerHTML = `
            #result_list th {
                position: relative;
            }
            #result_list .col-resizer {
                background: transparent;
            }
            #result_list .col-resizer:hover {
                background: var(--hairline-color, #ccc);
            }
        `;
        document.head.appendChild(style);
    }

    // --- Initial Setup ---
    injectResizeStyles();
    makeColumnsResizable();
});

To inject a JS file into your Django admin, you have these main options:

  1. By ModelAdmin Media class Add a Media class to your ModelAdmin and specify the JS file path:
class MyModelAdmin(admin.ModelAdmin):
    class Media:
        js = ('admin/js/changelist_resizable_columns.js',)

This loads the JS only for that admin.

  1. Globally by admin templates Override admin/base_site.html or admin/change_list.html and add a <script> tag:
{% extends 'admin/base_site.html' %}
{% load static %}

{% block extrahead %}
    {{ block.super }}
    <script src="{% static 'admin/js/changelist_resizable_columns.js' %}"></script>
{% endblock %}

This loads the JS for all admin pages. You must create the HTML file within the templates directory configured in Django's settings. In this case, it would look like this: templates/admin

The script below shows how to add the following features to the columns of the listview table.

  • Adds navigation buttons, one to the right and one to the left of the table, so you can easily scroll horizontally.
  • By pressing and holding the Ctrl key, you can drag the table and scroll horizontally with the mouse.

In the static directory, which is configured in the django settings STATICFILES_DIRS, create the file scroll_to_top.js inside /admin/js. For example, if your STATICFILES_DIRS is mapped to the statics directory, the file would look like this /statics/admin.js/scroll_to_top.js.

// changelist_scroll_arrow.js
// The original code for the snippet is here: https://gist.github.com/duducp/0c5b32f7936b924b39c10e5983de1199#file-changelist-scroll-arrow-md

// Wait for the DOM to be fully loaded before running the script
document.addEventListener('DOMContentLoaded', function() {
    const results = document.querySelector('.results');
    if (!results) return;

    // Look for the table inside the results div
    const table = results.querySelector('table');
    if (!table) return;

    // Create arrow buttons
    function createArrowButton(direction) {
        const btn = document.createElement('div');
        btn.className = `scroll-arrow scroll-arrow-${direction}`;
        btn.setAttribute('role', 'button');
        btn.setAttribute('tabindex', '0');
        btn.setAttribute('aria-label', direction === 'right' ? 'Scroll right' : 'Scroll left');
        // Softer SVG arrow, Django admin style
        btn.innerHTML = direction === 'right'
            ? `<svg width="20" height="64" viewBox="0 0 32 64" fill="none" xmlns="http://www.w3.org/2000/svg">
                <polyline points="12,16 22,32 12,48" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
               </svg>`
            : `<svg width="20" height="64" viewBox="0 0 32 64" fill="none" xmlns="http://www.w3.org/2000/svg">
                <polyline points="20,16 10,32 20,48" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
               </svg>`;
        btn.style.display = 'none';
        btn.style.pointerEvents = 'auto';
        return btn;
    }

    const leftArrow = createArrowButton('left');
    const rightArrow = createArrowButton('right');
    results.appendChild(leftArrow);
    results.appendChild(rightArrow);

    // Inject dynamic styles
    function injectArrowStyles() {
        if (document.getElementById('scroll-arrow-style')) return;
        const style = document.createElement('style');
        style.id = 'scroll-arrow-style';
        style.innerHTML = `
.results {
    position: relative;
    overflow-x: auto;
}
.scroll-arrow {
    position: absolute;
    top: 0;
    width: 32px;
    min-width: 32px;
    height: 100%;
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 100;
    cursor: pointer;
    opacity: 0.8;
    transition: opacity 0.2s;
    user-select: none;
    pointer-events: auto;
}
.scroll-arrow-left {
    border-radius: 0;
    background: linear-gradient(
        to right,
        var(--body-bg) 40%,
        transparent 100%
    );
}
.scroll-arrow-right {
    border-radius: 0;
    background: linear-gradient(
        to left,
        var(--body-bg) 40%,
        transparent 100%
    );
}
.scroll-arrow svg polyline {
    stroke: var(--link-fg);
}
        `;
        document.head.appendChild(style);
    }
    injectArrowStyles();

    // Function to position arrows correctly
    function positionArrows() {
        const scrollLeft = results.scrollLeft;
        leftArrow.style.left = scrollLeft + 'px';
        rightArrow.style.right = (-scrollLeft) + 'px';
    }

    // Function to check for horizontal overflow
    function updateArrows() {
        const maxScrollLeft = results.scrollWidth - results.clientWidth;
        const scrollLeft = Math.round(results.scrollLeft);

        leftArrow.style.display = scrollLeft > 0 ? 'flex' : 'none';
        rightArrow.style.display = scrollLeft < maxScrollLeft ? 'flex' : 'none';

        positionArrows();
    }

    // Smooth scroll when clicking the arrows
    function scrollByAmount(amount) {
        results.scrollBy({ left: amount, behavior: 'smooth' });
    }

    leftArrow.addEventListener('click', () => scrollByAmount(-results.clientWidth * 0.8));
    rightArrow.addEventListener('click', () => scrollByAmount(results.clientWidth * 0.8));
    leftArrow.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') leftArrow.click(); });
    rightArrow.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') rightArrow.click(); });

    // Update arrows on scroll or resize
    results.addEventListener('scroll', updateArrows);
    window.addEventListener('resize', updateArrows);

    // Initial update on load
    setTimeout(updateArrows, 200);
    // Update when table content changes (e.g. AJAX or filters)
    const observer = new MutationObserver(updateArrows);
    observer.observe(results, { childList: true, subtree: true });

    // Drag-to-scroll with CTRL
    let isCtrlPressed = false;
    let isDragging = false;
    let dragStartX = 0;
    let scrollStartX = 0;

    // Listen for CTRL key
    document.addEventListener('keydown', function(e) {
        if (e.key === 'Control') {
            isCtrlPressed = true;
            results.style.cursor = 'grab';
        }
    });
    document.addEventListener('keyup', function(e) {
        if (e.key === 'Control') {
            isCtrlPressed = false;
            isDragging = false;
            results.style.cursor = '';
        }
    });

    results.addEventListener('mousedown', function(e) {
        if (!isCtrlPressed) return;
        isDragging = true;
        dragStartX = e.clientX;
        scrollStartX = results.scrollLeft;
        results.style.cursor = 'grab';
        e.preventDefault();
    });

    document.addEventListener('mousemove', function(e) {
        if (!isDragging) return;
        const dx = e.clientX - dragStartX;
        results.scrollLeft = scrollStartX - dx;
    });

    document.addEventListener('mouseup', function() {
        if (isDragging) {
            isDragging = false;
            results.style.cursor = '';
        }
    });
});

To inject a JS file into your Django admin, you have these main options:

  1. By ModelAdmin Media class Add a Media class to your ModelAdmin and specify the JS file path:
class MyModelAdmin(admin.ModelAdmin):
    class Media:
        js = ('admin/js/scroll_to_top.js',)

This loads the JS only for that admin.

  1. Globally by admin templates Override admin/base_site.html or admin/change_list.html and add a <script> tag:
{% extends 'admin/base_site.html' %}
{% load static %}

{% block extrahead %}
    {{ block.super }}
    <script src="{% static 'admin/js/scroll_to_top.js' %}"></script>
{% endblock %}

This loads the JS for all admin pages. You must create the HTML file within the templates directory configured in Django's settings. In this case, it would look like this: templates/admin

The script below shows how to add the back to top button in Django Admin.

In the static directory, which is configured in the django settings STATICFILES_DIRS, create the file scroll_to_top.js inside /admin/js. For example, if your STATICFILES_DIRS is mapped to the statics directory, the file would look like this /statics/admin.js/scroll_to_top.js.

// scroll_to_top.js
// The original code for the snippet is here: https://gist.github.com/duducp/0c5b32f7936b924b39c10e5983de1199#file-scroll-to-top-md

// Wait for the DOM to be fully loaded before running the script
document.addEventListener('DOMContentLoaded', function() {
    // Create the button
    const btn = document.createElement('button');
    btn.id = 'scroll-to-top-btn';
    btn.innerHTML = `
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none">
            <path d="M12 5v14M12 5l-7 7M12 5l7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
        </svg>
    `;
    Object.assign(btn.style, {
        position: 'fixed',
        right: '30px',
        bottom: '30px',
        width: '48px',
        height: '48px',
        borderRadius: '50%',
        border: 'none',
        // background será ajustado dinamicamente abaixo
        color: 'var(--link-fg, #333)',
        boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
        cursor: 'pointer',
        display: 'none',
        alignItems: 'center',
        justifyContent: 'center',
        zIndex: '9999',
        padding: '0'
    });

    // Ajusta a cor de fundo conforme o tema do Django (light/dark)
    function setButtonBackground() {
        const body = document.body;
        const computed = getComputedStyle(body);

        // Try to pick up the background color of the theme
        let bg = computed.getPropertyValue('--body-bg').trim();
        if (!bg) {
            // Fallback to White
            bg = '#fff';
        }

        // Detects dark theme by the value of the background or body class
        let isDark = false;
        if (body.classList.contains('theme-dark') || (bg && (bg.startsWith('#1') || bg.startsWith('rgb(2') || bg.startsWith('rgb(3')))) {
            isDark = true;
        }
        // Applies transparency
        btn.style.background = isDark
            ? 'rgba(30, 30, 30, 0.7)'
            : 'rgba(255, 255, 255, 0.7)';
    }

    setButtonBackground();

    // Updates the button color if the theme changes dynamically
    const observer = new MutationObserver(setButtonBackground);
    observer.observe(document.body, { attributes: true, attributeFilter: ['class', 'style'] });

    // Also listens for click events in theme-toggle to ensure immediate update
    const themeToggle = document.querySelector('.theme-toggle');
    if (themeToggle) {
        themeToggle.addEventListener('click', function() {
            // Pequeno delay para garantir que a classe do body já foi alterada
            setTimeout(setButtonBackground, 10);
        });
    }

    // Show/hide button as you scroll
    window.addEventListener('scroll', function() {
        if (window.scrollY > 100) {
            btn.style.display = 'flex';
        } else {
            btn.style.display = 'none';
        }
    });

    // Smooth scroll to the top on click
    btn.addEventListener('click', function() {
        window.scrollTo({ top: 0, behavior: 'smooth' });
    });

    document.body.appendChild(btn);
});

To inject a JS file into your Django admin, you have these main options:

  1. By ModelAdmin Media class Add a Media class to your ModelAdmin and specify the JS file path:
class MyModelAdmin(admin.ModelAdmin):
    class Media:
        js = ('admin/js/scroll_to_top.js',)

This loads the JS only for that admin.

  1. Globally by admin templates Override admin/base_site.html or admin/change_list.html and add a <script> tag:
{% extends 'admin/base_site.html' %}
{% load static %}

{% block extrahead %}
    {{ block.super }}
    <script src="{% static 'admin/js/scroll_to_top.js' %}"></script>
{% endblock %}

This loads the JS for all admin pages. You must create the HTML file within the templates directory configured in Django's settings. In this case, it would look like this: templates/admin

@duducp
Copy link
Author

duducp commented Jul 18, 2025

Header fixed at the top

header

@duducp
Copy link
Author

duducp commented Jul 18, 2025

Scroll to top

scroll-to-top

@duducp
Copy link
Author

duducp commented Jul 18, 2025

Hide filter sidebar

sidebar

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment