You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
324 lines
10 KiB
324 lines
10 KiB
{% extends "portal_base.html" %} |
|
{% block portal_content %} |
|
|
|
<style> |
|
.image-workshop-wrap { max-width: 1200px; margin: 0 auto; } |
|
.iw-toolbar { display:flex; gap:10px; flex-wrap:wrap; margin: 12px 0 18px; } |
|
.iw-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:14px; } |
|
.iw-tile { background:rgba(255,255,255,.04); border:1px solid rgba(255,255,255,.12); border-radius:14px; padding:10px; } |
|
.iw-tile img { width:100%; height:130px; object-fit:cover; border-radius:10px; cursor:pointer; background:#111; } |
|
.iw-name { margin-top:8px; font-weight:700; word-break:break-word; font-size:.9rem; } |
|
.iw-mini { display:flex; gap:6px; flex-wrap:wrap; margin-top:8px; } |
|
.iw-mini button, .iw-toolbar button, .iw-modal-actions button { cursor:pointer; } |
|
|
|
.iw-modal { |
|
display:none; position:fixed; z-index:9999; inset:0; |
|
background:rgba(0,0,0,.82); padding:30px; overflow:auto; |
|
} |
|
.iw-modal-inner { |
|
max-width:1100px; margin:0 auto; background:#0f172a; |
|
border:1px solid rgba(255,255,255,.14); border-radius:18px; padding:18px; |
|
} |
|
.iw-preview-wrap { display:flex; justify-content:center; background:#050816; border-radius:14px; padding:16px; min-height:360px; } |
|
#modalImage { max-width:100%; max-height:70vh; object-fit:contain; transition:filter .15s, transform .15s; } |
|
.iw-modal-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:14px; align-items:center; } |
|
.iw-modal-actions input { min-width:260px; padding:8px; } |
|
.iw-note { opacity:.8; margin-top:8px; } |
|
|
|
.iw-modal-actions select { |
|
background:#0f172a; |
|
color:#e6eefc; |
|
border:1px solid rgba(255,255,255,0.2); |
|
border-radius:8px; |
|
padding:6px 10px; |
|
} |
|
|
|
|
|
.iw-modal-actions select, |
|
.iw-modal-actions select option, |
|
#formatSelect, |
|
#formatSelect option, |
|
#sizeSelect, |
|
#sizeSelect option { |
|
background-color: #0f172a !important; |
|
color: #e6eefc !important; |
|
border: 1px solid rgba(255,255,255,0.28) !important; |
|
} |
|
|
|
.iw-modal-actions select, |
|
#formatSelect, |
|
#sizeSelect { |
|
border-radius: 8px !important; |
|
padding: 8px 10px !important; |
|
min-width: 160px !important; |
|
} |
|
|
|
</style> |
|
|
|
<div class="image-workshop-wrap"> |
|
<div class="portal-page-header"> |
|
<div> |
|
<h1 class="portal-page-title">Image Workshop</h1> |
|
<p class="portal-page-subtitle">Double-click an image to edit it. Max 25 images per batch.</p> |
|
</div> |
|
<div class="portal-toolbar"> |
|
<a class="portal-btn" href="/devices/{{ device_id }}/files?path=images">Back to Images</a> |
|
<a class="portal-btn" href="/dashboard">Dashboard</a> |
|
<a class="portal-btn" href="/health">Health</a> |
|
</div> |
|
</div> |
|
|
|
<div class="service-card"> |
|
<div class="service-card-body"> |
|
<div class="iw-toolbar"> |
|
<button type="button" onclick="bulkRotate(0)">Original Rotation</button> |
|
<button type="button" onclick="bulkRotate(90)">Rotate 90</button> |
|
<button type="button" onclick="bulkRotate(180)">Rotate 180</button> |
|
<button type="button" onclick="bulkRotate(270)">Rotate 270</button> |
|
<button type="button" onclick="processAll()">Save All Changed</button> |
|
</div> |
|
<div id="grid" class="iw-grid"></div> |
|
</div> |
|
</div> |
|
</div> |
|
|
|
<div id="editorModal" class="iw-modal"> |
|
<div class="iw-modal-inner"> |
|
<h2 id="modalTitle">Edit Image</h2> |
|
<div class="iw-preview-wrap"> |
|
<img id="modalImage" src=""> |
|
</div> |
|
|
|
<div class="iw-modal-actions"> |
|
<button type="button" onclick="modalRotate(0)">Original</button> |
|
<button type="button" onclick="modalRotate(90)">Rotate 90</button> |
|
<button type="button" onclick="modalRotate(180)">Rotate 180</button> |
|
<button type="button" onclick="modalRotate(270)">Rotate 270</button> |
|
<button type="button" onclick="modalFilter(null)">Normal</button> |
|
<button type="button" onclick="modalFilter('bw')">B/W</button> |
|
<button type="button" onclick="modalFilter('sepia')">Sepia</button> |
|
</div> |
|
|
|
<div class="iw-modal-actions"> |
|
<label>New image name:</label> |
|
<input id="saveName" type="text" placeholder="required name, no extension"> |
|
</div> |
|
|
|
<div class="iw-modal-actions"> |
|
<label>Format:</label> |
|
<select id="formatSelect"> |
|
<option value="">Keep Format</option> |
|
<option value="jpg">JPG</option> |
|
<option value="webp">WebP</option> |
|
<option value="png">PNG</option> |
|
</select> |
|
|
|
<label>Size:</label> |
|
<select id="sizeSelect"> |
|
<option value="">Original Size</option> |
|
<option value="2000">2000px</option> |
|
<option value="1600" selected>1600px Web</option> |
|
<option value="1200">1200px Small Web</option> |
|
<option value="800">800px Preview</option> |
|
</select> |
|
</div> |
|
|
|
<div class="iw-modal-actions"> |
|
<button type="button" onclick="saveCurrentImage()">Save Image</button> |
|
<button type="button" onclick="revertCurrent()">Revert to Original</button> |
|
<button type="button" onclick="closeModal()">Close</button> |
|
</div> |
|
|
|
<p class="iw-note">Saving creates a new image in the images folder. Original files are not overwritten.</p> |
|
</div> |
|
</div> |
|
|
|
<script> |
|
let items = []; |
|
let state = {}; |
|
let currentId = null; |
|
|
|
function safeName(v){ return (v || "image").toString(); } |
|
|
|
function loadSelection() { |
|
try { items = JSON.parse(localStorage.getItem("imageSelection") || "[]"); } |
|
catch { items = []; } |
|
|
|
if (items.length > 25) { |
|
alert("Image Workshop is limited to 25 images per batch."); |
|
items = items.slice(0, 25); |
|
} |
|
|
|
items.forEach(i => { |
|
if (!state[i.id]) state[i.id] = { rotation: 0, filter: null, format: null, name: "" }; |
|
}); |
|
|
|
render(); |
|
} |
|
|
|
function render() { |
|
const grid = document.getElementById("grid"); |
|
if (!items.length) { |
|
grid.innerHTML = '<div class="job-empty">No images staged.</div>'; |
|
return; |
|
} |
|
|
|
grid.innerHTML = ""; |
|
|
|
items.forEach(item => { |
|
const s = state[item.id] || { rotation:0, filter:null }; |
|
const filename = safeName(item.display_filename || item.original_filename || item.filename || item.name); |
|
const filterCss = s.filter === "bw" ? "grayscale(1)" : (s.filter === "sepia" ? "sepia(1)" : "none"); |
|
|
|
const div = document.createElement("div"); |
|
div.className = "iw-tile"; |
|
div.innerHTML = ` |
|
<img ondblclick="openModal('${item.id}')" src="/files/${item.id}/thumb" style="transform:rotate(${s.rotation}deg);filter:${filterCss};"> |
|
<div class="iw-name">${filename}</div> |
|
<div class="iw-mini"> |
|
<button type="button" onclick="rotateOne('${item.id}',0)">0</button> |
|
<button type="button" onclick="rotateOne('${item.id}',90)">90</button> |
|
<button type="button" onclick="rotateOne('${item.id}',180)">180</button> |
|
<button type="button" onclick="rotateOne('${item.id}',270)">270</button> |
|
</div> |
|
`; |
|
grid.appendChild(div); |
|
}); |
|
} |
|
|
|
function rotateOne(id, deg) { |
|
state[id].rotation = deg; |
|
render(); |
|
} |
|
|
|
function bulkRotate(deg) { |
|
Object.keys(state).forEach(id => state[id].rotation = deg); |
|
render(); |
|
} |
|
|
|
function getItem(id) { |
|
return items.find(x => String(x.id) === String(id)); |
|
} |
|
|
|
function openModal(id) { |
|
currentId = id; |
|
const item = getItem(id); |
|
const s = state[id]; |
|
document.getElementById("modalTitle").textContent = safeName(item.filename || item.display_filename || item.original_filename || item.name); |
|
document.getElementById("modalImage").src = "/files/" + id + "/inline"; |
|
document.getElementById("saveName").value = s.name || ""; |
|
const fmt = document.getElementById("formatSelect"); |
|
const size = document.getElementById("sizeSelect"); |
|
if (fmt) fmt.value = s.format || ""; |
|
if (size) size.value = s.max_size || "1600"; |
|
applyModalPreview(); |
|
document.getElementById("editorModal").style.display = "block"; |
|
} |
|
|
|
function closeModal() { |
|
document.getElementById("editorModal").style.display = "none"; |
|
currentId = null; |
|
} |
|
|
|
function applyModalPreview() { |
|
if (!currentId) return; |
|
const s = state[currentId]; |
|
const img = document.getElementById("modalImage"); |
|
img.style.transform = `rotate(${s.rotation || 0}deg)`; |
|
img.style.filter = s.filter === "bw" ? "grayscale(1)" : (s.filter === "sepia" ? "sepia(1)" : "none"); |
|
} |
|
|
|
function modalRotate(deg) { |
|
state[currentId].rotation = deg; |
|
applyModalPreview(); |
|
render(); |
|
} |
|
|
|
function modalFilter(f) { |
|
state[currentId].filter = f; |
|
applyModalPreview(); |
|
render(); |
|
} |
|
|
|
function revertCurrent() { |
|
state[currentId] = { rotation: 0, filter: null, format: null, name: "" }; |
|
document.getElementById("saveName").value = ""; |
|
applyModalPreview(); |
|
render(); |
|
} |
|
|
|
async function saveCurrentImage() { |
|
if (!currentId) return; |
|
const name = document.getElementById("saveName").value.trim(); |
|
if (!name) { |
|
alert("Please enter a name for the new image."); |
|
return; |
|
} |
|
|
|
state[currentId].name = name; |
|
state[currentId].format = document.getElementById("formatSelect").value || null; |
|
state[currentId].max_size = document.getElementById("sizeSelect").value || null; |
|
|
|
const item = getItem(currentId); |
|
const payload = { items: [item], state: { [currentId]: state[currentId] } }; |
|
|
|
const r = await fetch("/api/image/process", { |
|
method: "POST", |
|
headers: {"Content-Type": "application/json"}, |
|
body: JSON.stringify(payload) |
|
}); |
|
|
|
const d = await r.json(); |
|
if (!d.ok) { |
|
alert(d.error || "Processing failed"); |
|
return; |
|
} |
|
|
|
alert("Saved image."); |
|
closeModal(); |
|
} |
|
|
|
async function processAll() { |
|
const changed = items.filter(item => { |
|
const s = state[item.id]; |
|
return s && (s.rotation || s.filter || s.format || s.name); |
|
}); |
|
|
|
if (!changed.length) { |
|
alert("No changed images to save."); |
|
return; |
|
} |
|
|
|
for (const item of changed) { |
|
if (!state[item.id].name) { |
|
alert("Each changed image needs a new name before saving."); |
|
openModal(item.id); |
|
return; |
|
} |
|
} |
|
|
|
const r = await fetch("/api/image/process", { |
|
method: "POST", |
|
headers: {"Content-Type": "application/json"}, |
|
body: JSON.stringify({ items: changed, state: state }) |
|
}); |
|
|
|
const d = await r.json(); |
|
if (!d.ok) { |
|
alert(d.error || "Processing failed"); |
|
return; |
|
} |
|
|
|
alert("Saved " + d.processed.length + " image(s)."); |
|
} |
|
|
|
document.getElementById("editorModal").addEventListener("click", function(e){ |
|
if (e.target === this) closeModal(); |
|
}); |
|
|
|
loadSelection(); |
|
</script> |
|
|
|
{% endblock %} |
|
|
|
|
|
|