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


<!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>
<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>
<div class="section" id="text-fields-section" style="display:none;">
<h2>[Dynamic Input Fields]</h2>
<div id="dynamic-inputs-list">
</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>
<div id="preview-modal">
<button id="close-preview">×</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 = '×';
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>