Add drag-and-drop page organiser template
This commit is contained in:
1 parent
9e0fc343f7
commit
2e2f2d66d6
1 file changed
+468
@@ -0,0 +1,468 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{# Page organiser for image instructions.
|
||||
Created by: Jess Rogerson (yelling commands at Claude.AI) #}
|
||||
|
||||
{% block title %}Organise Pages - {{ set.set_number }}: {{ set.set_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<style>
|
||||
.organiser-toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
background-color: #1a1a1a;
|
||||
color: #fff;
|
||||
padding: 15px 20px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.organiser-body {
|
||||
background-color: #f1f1f1;
|
||||
border-radius: 0 0 8px 8px;
|
||||
padding: 20px;
|
||||
min-height: 50vh;
|
||||
}
|
||||
|
||||
.page-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-tile {
|
||||
background: #fff;
|
||||
border: 2px solid #d7d7d7;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.page-tile:focus-visible {
|
||||
outline: 3px solid #0d6efd;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.page-tile.dragging {
|
||||
opacity: .4;
|
||||
}
|
||||
|
||||
.page-tile.drop-before {
|
||||
border-left-color: #0d6efd;
|
||||
box-shadow: inset 4px 0 0 #0d6efd;
|
||||
}
|
||||
|
||||
.page-tile.drop-after {
|
||||
border-right-color: #0d6efd;
|
||||
box-shadow: inset -4px 0 0 #0d6efd;
|
||||
}
|
||||
|
||||
.page-tile .thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 4;
|
||||
object-fit: contain;
|
||||
background: #fafafa;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-tile .tile-foot {
|
||||
padding: 6px 8px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
|
||||
.page-tile .file-name {
|
||||
font-size: .72rem;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
line-height: 1.2;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.seq-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 6px;
|
||||
background: rgba(0, 0, 0, .78);
|
||||
color: #fff;
|
||||
border-radius: 14px;
|
||||
padding: 2px 11px;
|
||||
font-weight: 700;
|
||||
font-size: .82rem;
|
||||
}
|
||||
|
||||
.hint-badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
background: rgba(255, 255, 255, .92);
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 14px;
|
||||
padding: 1px 8px;
|
||||
font-size: .7rem;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.tile-buttons {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.tile-buttons .btn {
|
||||
padding: 1px 6px;
|
||||
font-size: .72rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.jump-input {
|
||||
width: 100%;
|
||||
font-size: .75rem;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.save-bar {
|
||||
position: fixed;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
padding: 12px 24px;
|
||||
border-radius: 40px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
|
||||
z-index: 1000;
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.save-bar.visible {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-tile { transition: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="organiser-toolbar">
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div>
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-arrows-move"></i> Organise pages
|
||||
</h5>
|
||||
<small class="text-muted">
|
||||
{{ set.set_number }}: {{ set.set_name }} ·
|
||||
<span id="pageCount">{{ pages|length }}</span> pages
|
||||
</small>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<select class="form-select form-select-sm" id="sortStrategy" style="width: auto;">
|
||||
{% for value, label in strategies %}
|
||||
<option value="{{ value }}">{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button class="btn btn-sm btn-warning" id="autoSortBtn">
|
||||
<i class="bi bi-sort-numeric-down"></i> Sort
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-light" id="reverseBtn">
|
||||
<i class="bi bi-arrow-down-up"></i> Reverse
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-light" id="renumberBtn">
|
||||
<i class="bi bi-123"></i> Renumber
|
||||
</button>
|
||||
<a href="{{ url_for('instructions.image_viewer', set_id=set.id) }}"
|
||||
class="btn btn-sm btn-outline-light">
|
||||
<i class="bi bi-book"></i> Preview
|
||||
</a>
|
||||
<a href="{{ url_for('sets.view_set', set_id=set.id) }}"
|
||||
class="btn btn-sm btn-outline-light">
|
||||
<i class="bi bi-x-lg"></i> Close
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="organiser-body">
|
||||
{% if not audit.is_clean %}
|
||||
<div class="alert alert-warning py-2">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
Numbering needs a tidy-up:
|
||||
{% if audit.duplicates %}
|
||||
duplicates at {{ audit.duplicates|join(', ') }}.
|
||||
{% endif %}
|
||||
{% if audit.gaps %}
|
||||
missing {{ audit.gaps|join(', ') }}.
|
||||
{% endif %}
|
||||
{% if audit.unnumbered %}
|
||||
{{ audit.unnumbered }} with no number.
|
||||
{% endif %}
|
||||
Renumber fixes all of it without moving anything.
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p class="text-muted small">
|
||||
Drag a page onto another to drop it there, or type a position in the box
|
||||
under any page and press Enter. Keyboard: tab to a page, then use the
|
||||
arrow buttons. Nothing is written until you save.
|
||||
</p>
|
||||
|
||||
<div class="page-grid" id="pageGrid">
|
||||
{% for page in pages %}
|
||||
<div class="page-tile" draggable="true" tabindex="0"
|
||||
data-id="{{ page.id }}"
|
||||
data-name="{{ page.file_name }}">
|
||||
<span class="seq-badge">{{ loop.index }}</span>
|
||||
{% if page_hints.get(page.id) %}
|
||||
<span class="hint-badge" title="Number found in the filename">
|
||||
#{{ page_hints[page.id] }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<img class="thumb" loading="lazy"
|
||||
alt="Page {{ loop.index }}"
|
||||
src="/static/uploads/{{ (page.thumbnail_path or page.file_path).replace('\\', '/') }}">
|
||||
<div class="tile-foot">
|
||||
<span class="file-name" title="{{ page.file_name }}">{{ page.file_name }}</span>
|
||||
<div class="tile-buttons">
|
||||
<button type="button" class="btn btn-outline-secondary move-left"
|
||||
title="Move earlier">←</button>
|
||||
<button type="button" class="btn btn-outline-secondary move-right"
|
||||
title="Move later">→</button>
|
||||
<input type="number" min="1" class="jump-input" draggable="false"
|
||||
placeholder="#" aria-label="Move to position">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="save-bar" id="saveBar">
|
||||
<span id="saveStatus">Order changed</span>
|
||||
<button class="btn btn-success btn-sm" id="saveBtn">
|
||||
<i class="bi bi-check-lg"></i> Save order
|
||||
</button>
|
||||
<button class="btn btn-outline-light btn-sm" id="revertBtn">
|
||||
Discard changes
|
||||
</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_js %}
|
||||
<script>
|
||||
(function () {
|
||||
const grid = document.getElementById('pageGrid');
|
||||
const saveBar = document.getElementById('saveBar');
|
||||
const saveStatus = document.getElementById('saveStatus');
|
||||
let leavingOnPurpose = false;
|
||||
|
||||
const urls = {
|
||||
reorder: "{{ url_for('instructions.reorder', set_id=set.id) }}",
|
||||
autoSort: "{{ url_for('instructions.auto_sort', set_id=set.id) }}",
|
||||
renumber: "{{ url_for('instructions.renumber', set_id=set.id) }}"
|
||||
};
|
||||
|
||||
const savedOrder = tiles().map(t => t.dataset.id);
|
||||
let dragged = null;
|
||||
|
||||
function tiles() {
|
||||
return Array.from(grid.querySelectorAll('.page-tile'));
|
||||
}
|
||||
|
||||
function currentOrder() {
|
||||
return tiles().map(t => t.dataset.id);
|
||||
}
|
||||
|
||||
function isDirty() {
|
||||
const now = currentOrder();
|
||||
return now.length !== savedOrder.length ||
|
||||
now.some((id, i) => id !== savedOrder[i]);
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
tiles().forEach((tile, index) => {
|
||||
tile.querySelector('.seq-badge').textContent = index + 1;
|
||||
tile.querySelector('.thumb').alt = 'Page ' + (index + 1);
|
||||
});
|
||||
saveBar.classList.toggle('visible', isDirty());
|
||||
saveStatus.textContent = 'Order changed';
|
||||
}
|
||||
|
||||
function moveTile(tile, newIndex) {
|
||||
const all = tiles();
|
||||
const clamped = Math.max(0, Math.min(newIndex, all.length - 1));
|
||||
const reference = all.filter(t => t !== tile)[clamped];
|
||||
if (reference) {
|
||||
grid.insertBefore(tile, reference);
|
||||
} else {
|
||||
grid.appendChild(tile);
|
||||
}
|
||||
refresh();
|
||||
}
|
||||
|
||||
// A draggable parent stops some browsers letting you click into the
|
||||
// position box, so the tile stops being draggable while it has focus.
|
||||
grid.addEventListener('focusin', function (e) {
|
||||
if (e.target.classList.contains('jump-input')) {
|
||||
e.target.closest('.page-tile').draggable = false;
|
||||
}
|
||||
});
|
||||
|
||||
grid.addEventListener('focusout', function (e) {
|
||||
if (e.target.classList.contains('jump-input')) {
|
||||
e.target.closest('.page-tile').draggable = true;
|
||||
}
|
||||
});
|
||||
|
||||
// --- drag and drop (desktop) -------------------------------------------
|
||||
|
||||
grid.addEventListener('dragstart', function (e) {
|
||||
const tile = e.target.closest('.page-tile');
|
||||
if (!tile) return;
|
||||
dragged = tile;
|
||||
tile.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', tile.dataset.id);
|
||||
});
|
||||
|
||||
grid.addEventListener('dragend', function () {
|
||||
if (dragged) dragged.classList.remove('dragging');
|
||||
tiles().forEach(t => t.classList.remove('drop-before', 'drop-after'));
|
||||
dragged = null;
|
||||
});
|
||||
|
||||
grid.addEventListener('dragover', function (e) {
|
||||
const tile = e.target.closest('.page-tile');
|
||||
if (!tile || !dragged || tile === dragged) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const box = tile.getBoundingClientRect();
|
||||
const before = (e.clientX - box.left) < box.width / 2;
|
||||
tiles().forEach(t => t.classList.remove('drop-before', 'drop-after'));
|
||||
tile.classList.add(before ? 'drop-before' : 'drop-after');
|
||||
});
|
||||
|
||||
grid.addEventListener('drop', function (e) {
|
||||
const tile = e.target.closest('.page-tile');
|
||||
if (!tile || !dragged || tile === dragged) return;
|
||||
e.preventDefault();
|
||||
|
||||
const box = tile.getBoundingClientRect();
|
||||
const before = (e.clientX - box.left) < box.width / 2;
|
||||
grid.insertBefore(dragged, before ? tile : tile.nextSibling);
|
||||
|
||||
tiles().forEach(t => t.classList.remove('drop-before', 'drop-after'));
|
||||
refresh();
|
||||
});
|
||||
|
||||
// --- buttons and the position box (touch and keyboard) -----------------
|
||||
|
||||
grid.addEventListener('click', function (e) {
|
||||
const tile = e.target.closest('.page-tile');
|
||||
if (!tile) return;
|
||||
const index = tiles().indexOf(tile);
|
||||
|
||||
if (e.target.closest('.move-left')) {
|
||||
moveTile(tile, index - 1);
|
||||
tile.focus();
|
||||
} else if (e.target.closest('.move-right')) {
|
||||
moveTile(tile, index + 1);
|
||||
tile.focus();
|
||||
}
|
||||
});
|
||||
|
||||
grid.addEventListener('keydown', function (e) {
|
||||
if (e.target.classList.contains('jump-input')) {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
const tile = e.target.closest('.page-tile');
|
||||
const wanted = parseInt(e.target.value, 10);
|
||||
if (!isNaN(wanted)) {
|
||||
moveTile(tile, wanted - 1);
|
||||
e.target.value = '';
|
||||
tile.focus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const tile = e.target.closest('.page-tile');
|
||||
if (!tile) return;
|
||||
const index = tiles().indexOf(tile);
|
||||
|
||||
if (e.key === 'ArrowLeft' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
moveTile(tile, index - 1);
|
||||
tile.focus();
|
||||
} else if (e.key === 'ArrowRight' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
moveTile(tile, index + 1);
|
||||
tile.focus();
|
||||
}
|
||||
});
|
||||
|
||||
// --- server calls ------------------------------------------------------
|
||||
|
||||
function post(url, body, button) {
|
||||
if (button) button.disabled = true;
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body || {})
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (data) {
|
||||
if (!response.ok) throw new Error(data.error || 'Request failed');
|
||||
return data;
|
||||
});
|
||||
}).catch(function (error) {
|
||||
alert(error.message);
|
||||
throw error;
|
||||
}).finally(function () {
|
||||
if (button) button.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('saveBtn').addEventListener('click', function () {
|
||||
const button = this;
|
||||
saveStatus.textContent = 'Saving...';
|
||||
post(urls.reorder, { order: currentOrder() }, button).then(function () {
|
||||
savedOrder.length = 0;
|
||||
currentOrder().forEach(id => savedOrder.push(id));
|
||||
saveStatus.textContent = 'Saved';
|
||||
saveBar.classList.remove('visible');
|
||||
}).catch(function () {
|
||||
saveStatus.textContent = 'Not saved';
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('revertBtn').addEventListener('click', function () {
|
||||
leavingOnPurpose = true;
|
||||
window.location.reload();
|
||||
});
|
||||
|
||||
document.getElementById('autoSortBtn').addEventListener('click', function () {
|
||||
const strategy = document.getElementById('sortStrategy').value;
|
||||
post(urls.autoSort, { strategy: strategy }, this)
|
||||
.then(function () { leavingOnPurpose = true; window.location.reload(); });
|
||||
});
|
||||
|
||||
document.getElementById('reverseBtn').addEventListener('click', function () {
|
||||
post(urls.autoSort, { strategy: 'reverse' }, this)
|
||||
.then(function () { leavingOnPurpose = true; window.location.reload(); });
|
||||
});
|
||||
|
||||
document.getElementById('renumberBtn').addEventListener('click', function () {
|
||||
post(urls.renumber, {}, this)
|
||||
.then(function () { leavingOnPurpose = true; window.location.reload(); });
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', function (e) {
|
||||
if (isDirty() && !leavingOnPurpose) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
Reference in new issue
Block a user