Files
lego-instructions-manager/app/templates/instructions/organise.html
T

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 }} &middot;
<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">&larr;</button>
<button type="button" class="btn btn-outline-secondary move-right"
title="Move later">&rarr;</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 %}