469 lines
15 KiB
HTML
469 lines
15 KiB
HTML
{% 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 %}
|