Improved Article Images Ordering WebApp

This is a follow-up to my article, "Made Web App to Order Images for My Posts". Since then, I asked the AI to improve the code and add more features:

Image Preview on Double-Click: since the sorting blocks show images as Square thumbnails, I needed a way to get the full version of an image when I needed to.

  • Entry Position on Mouse Hover: The app now shows the numbered position of an images when the mouse hovers over them.

  • Text Blocks & Text Editing: Addede Text Entries functionality . New blocks that appear as text in the output list. I used Dynamic Input Fields to edit their content.

With the current version, I can compose a whole article by writing paragraphs as Text Blocks and the images in between as image blocks. Then, I can re-order them as much as I want, with

Challenges: These improvements took 5 rounds of AI prompting, three times for feature adding, and two for bug fixing. For example, adding the mouse hovers functionality prevented the Drag-and-Drop functionality at first... Adding Text Blocks required two rounds as the first one didn't add the text fields for editing them.

The app can still be improved further...



Current Code: (Made with GLM 4.7 via Venice.ai)

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Image Grid Reorder Tool</title>
(html comment removed:  SortableJS for Drag and Drop )
<script src="https://cdnjs.cloudflare.com/ajax/libs/Sortable/1.15.2/Sortable.min.js"></script>
<style>
    body {
        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
        padding: 20px;
        background-color: #f4f4f9;
        color: #333;
        max-width: 1200px;
        margin: 0 auto;
    }
    .section {
        background: white;
        padding: 20px;
        border-radius: 8px;
        box-shadow: 0 2px 10px rgba(0,0,0,0.1);
        margin-bottom: 20px;
    }
    h2 { margin-top: 0; font-size: 1.2rem; }
    textarea {
        width: 100%;
        height: 100px;
        padding: 10px;
        border: 1px solid #ccc;
        border-radius: 4px;
        box-sizing: border-box;
        font-family: monospace;
        resize: vertical;
    }
    .controls {
        display: flex;
        align-items: center;
        gap: 20px;
        margin-top: 10px;
        flex-wrap: wrap;
    }
    input[type="number"] { width: 60px; padding: 5px; }
    button {
        padding: 10px 20px;
        background-color: #007bff;
        color: white;
        border: none;
        border-radius: 4px;
        cursor: pointer;
        font-weight: bold;
    }
    button:hover { background-color: #0056b3; }
    
    #add-text-btn { background-color: #17a2b8; }
    #add-text-btn:hover { background-color: #138496; }

    #image-grid {
        display: grid;
        gap: 15px;
        margin-top: 20px;
        min-height: 100px;
        border: 2px dashed #eee;
        padding: 10px;
    }
    .grid-item {
        background: #eee;
        border-radius: 4px;
        overflow: hidden;
        cursor: grab;
        position: relative;
        aspect-ratio: 1 / 1;
        display: flex;
        align-items: center;
        justify-content: center;
        border: 1px solid #ddd;
        user-select: none;
    }
    .grid-item:active { cursor: grabbing; }
    
    .grid-item img {
        width: 100%;
        height: 100%;
        object-fit: cover;
        display: block;
        pointer-events: none;
    }

    /* Text Block Styles */
    .grid-item[data-type="text"] {
        background-color: #007bff;
        color: white;
        border: 1px solid #0056b3;
    }
    .grid-item .text-content {
        width: 100%;
        height: 100%;
        display: flex;
        align-items: center;
        justify-content: center;
        text-align: center;
        font-size: 1rem;
        font-weight: bold;
        padding: 10px;
        outline: none;
        cursor: text;
        word-break: break-word;
        overflow: hidden;
        pointer-events: auto;
    }

    /* Delete Button */
    .delete-btn {
        position: absolute;
        top: 5px;
        right: 5px;
        width: 24px;
        height: 24px;
        background: rgba(255, 0, 0, 0.8);
        color: white;
        border-radius: 50%;
        border: none;
        font-size: 16px;
        line-height: 1;
        cursor: pointer;
        display: flex;
        align-items: center;
        justify-content: center;
        z-index: 10;
    }
    .delete-btn:hover { background: red; }

    /* Index Overlay (Hover) */
    .index-badge {
        position: absolute;
        top: 0; left: 0; right: 0; bottom: 0;
        background: rgba(255, 255, 255, 0.5);
        display: flex;
        align-items: center;
        justify-content: center;
        font-size: 3rem;
        font-weight: bold;
        color: #333;
        opacity: 0;
        transition: opacity 0.2s ease, pointer-events 0s;
        pointer-events: none; 
        z-index: 5;
    }
    .grid-item:hover .index-badge { opacity: 1; pointer-events: auto; }

    .sortable-ghost { opacity: 0.3; }
    
    /* Dynamic Inputs Section Styles */
    #dynamic-inputs-list {
        display: flex;
        flex-direction: column;
        gap: 10px;
    }
    .dynamic-input-row {
        display: flex;
        align-items: center;
        gap: 10px;
        background: #f9f9f9;
        padding: 10px;
        border-radius: 4px;
        border: 1px solid #eee;
    }
    .dynamic-input-label {
        font-weight: bold;
        color: #555;
        min-width: 60px;
    }
    .dynamic-input-field {
        flex-grow: 1;
        height: 40px;
        margin: 0;
        font-family: inherit;
    }

    #output-text { height: 150px; background-color: #f8f9fa; }
    .copy-btn { margin-top: 10px; background-color: #28a745; }

    /* Modal Styles */
    #preview-modal {
        display: none;
        position: fixed;
        top: 0; left: 0; width: 100%; height: 100%;
        background: rgba(0,0,0,0.85);
        z-index: 1000;
        align-items: center;
        justify-content: center;
        cursor: zoom-out;
    }
    #preview-modal.active { display: flex; }
    #preview-content {
        max-width: 90%;
        max-height: 90%;
        border-radius: 4px;
        box-shadow: 0 0 20px rgba(0,0,0,0.5);
        pointer-events: none;
    }
    #close-preview {
        position: absolute;
        top: 20px;
        right: 20px;
        color: white;
        font-size: 30px;
        cursor: pointer;
        background: none;
        border: none;
        z-index: 1001;
    }
</style>
</head>
<body>

<div class="section">
    <h2>[Input] Paste Text with Image URLs</h2>
    <textarea id="input-text" placeholder="Paste text here... The app will find all URLs ending in jpg, png, webp, etc."></textarea>
    <div class="controls">
        <div>
            <label for="grid-columns">Columns: </label>
            <input type="number" id="grid-columns" value="4" min="1" max="12">
        </div>
        <button id="generate-btn">[Generate Blocks Button]</button>
        <button id="add-text-btn">+ Add Text Block</button>
    </div>
</div>

<div class="section">
    <h2>[The Image Section] Drag to Reorder</h2>
    <div id="image-grid"></div>
</div>

(html comment removed:  NEW: Dynamic Input Fields Section )
<div class="section" id="text-fields-section" style="display:none;">
    <h2>[Dynamic Input Fields]</h2>
    <div id="dynamic-inputs-list">
        (html comment removed:  Inputs will be generated here )
    </div>
</div>

<div class="section">
    <h2>[The Output section]</h2>
    <textarea id="output-text" readonly placeholder="Reordered URLs and Text will appear here..."></textarea>
    <button class="copy-btn" onclick="copyOutput()">Copy to Clipboard</button>
</div>

(html comment removed:  Preview Modal )
<div id="preview-modal">
    <button id="close-preview">&times;</button>
    <img id="preview-content" src="" alt="Full size preview">
</div>

<script>
    const inputText = document.getElementById('input-text');
    const outputText = document.getElementById('output-text');
    const imageGrid = document.getElementById('image-grid');
    const generateBtn = document.getElementById('generate-btn');
    const addTextBtn = document.getElementById('add-text-btn');
    const gridColumns = document.getElementById('grid-columns');
    const previewModal = document.getElementById('preview-modal');
    const previewImg = document.getElementById('preview-content');
    const closePreviewBtn = document.getElementById('close-preview');
    const textFieldsSection = document.getElementById('text-fields-section');
    const dynamicInputsList = document.getElementById('dynamic-inputs-list');
    
    let sortableInstance = null;
    let isUpdatingInputs = false; // Flag to prevent loops

    // Initialize SortableJS
    function initSortable() {
        if (sortableInstance) sortableInstance.destroy();
        sortableInstance = new Sortable(imageGrid, {
            animation: 150,
            ghostClass: 'sortable-ghost',
            onEnd: updateOutput
        });
    }

    // Extract URLs from text
    function extractUrls(text) {
        const urlRegex = /https?:\/\/[^\s<]+?\.(jpg|jpeg|png|gif|webp|svg|bmp)/gi;
        return text.match(urlRegex) || [];
    }

    // Update the grid layout
    function updateGridLayout() {
        imageGrid.style.gridTemplateColumns = `repeat(${gridColumns.value}, 1fr)`;
    }

    // Unified Function to Create Grid Items (Image or Text)
    function createGridItem(type, content) {
        const div = document.createElement('div');
        div.className = 'grid-item';
        div.dataset.type = type;
        
        // Unique ID for linking inputs
        if (type === 'text') {
            div.dataset.uid = 'txt-' + Date.now() + Math.random().toString(36).substr(2, 9);
            div.dataset.content = content;
        }

        const badge = document.createElement('span');
        badge.className = 'index-badge';
        div.appendChild(badge);

        if (type === 'image') {
            div.dataset.url = content;
            const img = document.createElement('img');
            img.src = content;
            img.loading = "lazy";
            img.draggable = false;
            
            img.onerror = () => { 
                div.style.backgroundColor = '#ffcccc'; 
                div.style.border = '1px solid red';
                div.title = "Broken Link";
            };
            div.appendChild(img);
            
            div.addEventListener('dblclick', (e) => {
                if(!e.target.classList.contains('delete-btn')) openPreview(content);
            });

        } else if (type === 'text') {
            const textDiv = document.createElement('div');
            textDiv.contentEditable = true;
            textDiv.className = 'text-content';
            textDiv.innerText = content || "TEXT";
            
            textDiv.addEventListener('input', () => {
                div.dataset.content = textDiv.innerText;
                // Don't call updateOutput here directly to avoid input focus loss loops if we redraw inputs
                // But we do need to update the main output text area
                updateOutput(false); 
            });
            
            div.appendChild(textDiv);
        }

        const delBtn = document.createElement('button');
        delBtn.className = 'delete-btn';
        delBtn.innerHTML = '&times;';
        delBtn.title = "Remove Item";
        delBtn.addEventListener('click', (e) => {
            e.stopPropagation(); 
            div.remove();
            updateOutput(true); // Rebuild inputs on delete
        });
        div.appendChild(delBtn);

        return div;
    }

    // Generate the image blocks
    generateBtn.addEventListener('click', () => {
        const urls = extractUrls(inputText.value);
        imageGrid.innerHTML = ''; 
        
        if (urls.length === 0) {
            alert("No image URLs found.");
            return;
        }

        urls.forEach(url => {
            imageGrid.appendChild(createGridItem('image', url));
        });

        updateGridLayout();
        initSortable();
        updateOutput(true);
    });

    // Add Text Block Functionality
    addTextBtn.addEventListener('click', () => {
        const newItem = createGridItem('text', "TEXT");
        imageGrid.appendChild(newItem);
        updateOutput(true);
        
        if (!sortableInstance) initSortable();
    });

    // Sync Output Textarea and Input Fields
    function updateOutput(rebuildInputs = true) {
        const items = imageGrid.querySelectorAll('.grid-item');
        const outputData = [];
        let textBlockCount = 0;
        
        items.forEach((item, index) => {
            const badge = item.querySelector('.index-badge');
            if(badge) badge.innerText = index + 1;

            const type = item.dataset.type;
            if (type === 'image') {
                outputData.push(item.dataset.url);
            } else if (type === 'text') {
                const textDiv = item.querySelector('.text-content');
                const content = textDiv ? textDiv.innerText : item.dataset.content || "";
                item.dataset.content = content;
                outputData.push(content);
                textBlockCount++;
            }
        });

        outputText.value = outputData.join('\n');

        // Show/Hide Section
        textFieldsSection.style.display = textBlockCount > 0 ? 'block' : 'none';

        if (rebuildInputs && !isUpdatingInputs) {
            renderDynamicInputs();
        }
    }

    // Render the Dynamic Input Fields Section
    function renderDynamicInputs() {
        isUpdatingInputs = true; // Lock to prevent input event loop
        
        const textItems = Array.from(imageGrid.querySelectorAll('.grid-item[data-type="text"]'));
        
        // Save current focus if possible (simple implementation: if an input has focus, we try to restore it later)
        // Ideally in Vanilla JS, full re-render kills focus. 
        // We will assume user focuses one thing at a time.
        
        dynamicInputsList.innerHTML = ''; // Clear container

        textItems.forEach((item, index) => {
            // Determine Label: TEXT!, TEXT2, TEXT3...
            let labelText = "TEXT" + (index + 1);
            if (index === 0) labelText = "TEXT!";

            const row = document.createElement('div');
            row.className = 'dynamic-input-row';

            const label = document.createElement('div');
            label.className = 'dynamic-input-label';
            label.innerText = labelText;

            const input = document.createElement('textarea');
            input.className = 'dynamic-input-field';
            input.value = item.dataset.content || "";
            
            // When user types in input, update the grid item
            input.addEventListener('input', (e) => {
                item.dataset.content = e.target.value;
                const gridTextDiv = item.querySelector('.text-content');
                if(gridTextDiv) gridTextDiv.innerText = e.target.value;
                
                // Update main output only, don't rebuild inputs (to keep focus)
                updateOutput(false);
            });

            row.appendChild(label);
            row.appendChild(input);
            dynamicInputsList.appendChild(row);
        });

        isUpdatingInputs = false;
    }

    // Preview Modal Functions
    function openPreview(url) {
        previewImg.src = url;
        previewModal.classList.add('active');
    }

    function closePreview() {
        previewModal.classList.remove('active');
        setTimeout(() => { previewImg.src = ''; }, 200);
    }

    closePreviewBtn.addEventListener('click', (e) => {
        e.stopPropagation();
        closePreview();
    });

    previewModal.addEventListener('click', closePreview);

    // Listen for column changes in real-time
    gridColumns.addEventListener('input', updateGridLayout);

    // Copy Function
    function copyOutput() {
        outputText.select();
        document.execCommand('copy');
        alert('Copied to clipboard!');
    }
</script>

</body>
</html>

Posted Using INLEO



0
0
0.000
1 comments
avatar

Thanks for your contribution to the STEMsocial community. Feel free to join us on discord to get to know the rest of us!

Please consider delegating to the @stemsocial account (85% of the curation rewards are returned).

Consider setting @stemsocial as a beneficiary of this post's rewards if you would like to support the community and contribute to its mission of promoting science and education on Hive. 
 

0
0
0.000