otb-cloud secure encrypted backups
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

{% 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 %}