Complete photo deletion on disk, snappy lightbox, keyboard shortcuts, prefetching, and perfected documentation

This commit is contained in:
drjones
2026-07-20 21:08:07 -07:00
parent 5208c5ecef
commit c07fb9d971
3 changed files with 286 additions and 90 deletions

View File

@@ -218,6 +218,15 @@ input[type=color]{width:46px;height:32px;border:1px solid var(--line);border-rad
.result-desc2{font-size:11px;color:var(--txt);line-height:1.4;display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
/* ---------- toast notifications ---------- */
.toast-container{position:fixed;bottom:24px;right:24px;z-index:9999;display:flex;flex-direction:column;gap:8px;pointer-events:none}
.toast-msg{background:var(--panel2);border:1px solid var(--line);color:var(--txt);padding:10px 18px;border-radius:6px;font-size:12px;font-family:var(--font-sans);font-weight:500;box-shadow:0 8px 24px rgba(0,0,0,0.6);opacity:0;transform:translateY(12px);transition:all .25s ease;pointer-events:auto;display:flex;align-items:center;gap:8px}
.toast-msg.show{opacity:1;transform:translateY(0)}
.toast-msg.ok{border-color:var(--grn);color:var(--grn);box-shadow:0 0 16px rgba(74,222,128,.2)}
.toast-msg.error{border-color:var(--red);color:var(--red);box-shadow:0 0 16px rgba(248,113,113,.2)}
.toast-msg.info{border-color:var(--cyan);color:var(--cyan);box-shadow:0 0 16px rgba(34,211,238,.2)}
.load-more-row{display:flex;justify-content:center;padding:6px 14px 20px}
/* ---------- fullscreen viewer / editor lightbox ---------- */
@@ -448,6 +457,7 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
<button id="btnExportSelected">Export selected (JSON)</button>
<button id="btnExportAllCsv">Export all matches (CSV)</button>
<button id="btnDownloadAllZip">⬇ Download all matches (ZIP)</button>
<button id="btnBulkDelete" class="btn-danger">🗑 Delete selected</button>
</div>
<div class="search-results-grid" id="searchResultsGrid">
<div class="search-empty">Loading tagged photos…</div>
@@ -714,6 +724,7 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
<div class="lightbox-topbar">
<div class="lightbox-pos" id="lbPos">1 / 1</div>
<div class="lightbox-topbar-actions">
<button id="lbDeleteTop" class="btn-danger" title="Delete photo from disk (Delete / Backspace key)">🗑 Delete</button>
<button id="lbReveal">Reveal in Finder</button>
<button id="lbClose" class="btn-danger">✕ Close</button>
</div>
@@ -745,6 +756,7 @@ mark{background:rgba(34,211,238,.28);color:#fff;border-radius:2px;padding:0 1px}
<div class="editor-actions">
<button id="lbSave" class="btn-primary">✓ Save</button>
<button id="lbRedo">⌁ AI Redo</button>
<button id="lbDelete" class="btn-danger" style="grid-column:1/-1">🗑 Delete photo from disk</button>
</div>
</div>
</div>
@@ -860,6 +872,25 @@ function addLog(e){
}
function escapeHtml(s){return s.replace(/[&<>]/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;"}[c]))}
function showToast(msg, type="info", duration=3000){
let container = $("toastContainer");
if(!container){
container = document.createElement("div");
container.id = "toastContainer";
container.className = "toast-container";
document.body.appendChild(container);
}
const toast = document.createElement("div");
toast.className = `toast-msg ${type}`;
toast.innerHTML = `<span>${escapeHtml(msg)}</span>`;
container.appendChild(toast);
setTimeout(()=>toast.classList.add("show"), 10);
setTimeout(()=>{
toast.classList.remove("show");
setTimeout(()=>toast.remove(), 300);
}, duration);
}
function setBadge(st){
state=st;
const b=$("badge"); b.textContent=st.toUpperCase(); b.className="badge "+st;
@@ -1264,6 +1295,16 @@ function renderLightbox(){
img.onerror = ()=>{ $("lbLoading").textContent = "failed to load full image"; };
img.src = "/api/photo?path=" + encodeURIComponent(rec.path) + "&_=" + Date.now();
}
// Instant 0ms prefetching for adjacent photos
if(lbMode === "grid"){
if(lbIndex + 1 < searchResults.length && !VIDEO_EXT_RE.test(searchResults[lbIndex + 1].path)){
new Image().src = "/api/photo?path=" + encodeURIComponent(searchResults[lbIndex + 1].path);
}
if(lbIndex - 1 >= 0 && !VIDEO_EXT_RE.test(searchResults[lbIndex - 1].path)){
new Image().src = "/api/photo?path=" + encodeURIComponent(searchResults[lbIndex - 1].path);
}
}
}
async function lbGoto(delta){
@@ -1280,15 +1321,51 @@ async function lbGoto(delta){
renderLightbox();
}
async function deleteCurrentPhoto(){
const rec = currentLbRec();
if(!rec) return;
if(!confirm(`Permanently delete this photo from disk?\n\n${rec.path}`)) return;
const j = await api("/api/delete_photo", { path: rec.path });
if(j.ok){
showToast("Deleted " + os_basename(rec.path) + " from disk", "ok");
addLog({level:"ok", msg:`Deleted ${os_basename(rec.path)} from disk.`, ts:new Date().toTimeString().slice(0,8)});
if(lbMode === "grid"){
searchResults.splice(lbIndex, 1);
searchTotal = Math.max(0, searchTotal - 1);
if(searchResults.length === 0){
closeLightbox();
doSearch(true);
} else {
if(lbIndex >= searchResults.length) lbIndex = searchResults.length - 1;
renderLightbox();
renderSearchResults();
}
} else {
closeLightbox();
}
} else {
showToast(j.error || "Failed to delete photo", "error");
}
}
$("lbPrev").onclick = ()=>lbGoto(-1);
$("lbNext").onclick = ()=>lbGoto(1);
$("lbClose").onclick = closeLightbox;
if($("lbDelete")) $("lbDelete").onclick = deleteCurrentPhoto;
if($("lbDeleteTop")) $("lbDeleteTop").onclick = deleteCurrentPhoto;
document.addEventListener("keydown", e=>{
if(!$("lightbox").classList.contains("active")) return;
const tag = (e.target.tagName || "").toLowerCase();
if(tag === "input" || tag === "textarea" || tag === "select") return;
if(e.key === "Escape") closeLightbox();
else if(e.key === "ArrowLeft") lbGoto(-1);
else if(e.key === "ArrowRight") lbGoto(1);
else if(e.key === "Delete" || e.key === "Backspace"){
e.preventDefault();
deleteCurrentPhoto();
}
});
$("lbSave").onclick = async ()=>{
@@ -1300,6 +1377,7 @@ $("lbSave").onclick = async ()=>{
if(j.ok){
rec.desc = j.desc; rec.category = j.category;
if(lbMode === "grid") renderSearchResults();
showToast("Saved metadata for " + os_basename(rec.path), "ok");
addLog({level:"ok", msg:`Updated ${os_basename(rec.path)} metadata manually.`, ts:new Date().toTimeString().slice(0,8)});
}
};
@@ -1315,6 +1393,7 @@ $("lbRedo").onclick = async ()=>{
rec.desc = j.desc; rec.category = j.category; rec.route = j.route;
renderLightbox();
if(lbMode === "grid") renderSearchResults();
showToast("AI re-evaluated " + os_basename(rec.path), "ok");
addLog({level:"ok", msg:`AI re-evaluated ${os_basename(rec.path)} successfully.`, ts:new Date().toTimeString().slice(0,8)});
}
};
@@ -1329,6 +1408,7 @@ $("lbCopyPath").onclick = async ()=>{
if(!rec) return;
try{
await navigator.clipboard.writeText(rec.path);
showToast("File path copied to clipboard", "ok");
const btn = $("lbCopyPath");
const orig = btn.textContent;
btn.textContent = "✓ Copied";
@@ -1518,6 +1598,24 @@ $("btnExportAllCsv").onclick = ()=>{
const params = currentSearchParams({format:"csv"});
window.open("/api/export?" + params.toString(), "_blank");
};
if($("btnBulkDelete")){
$("btnBulkDelete").onclick = async ()=>{
if(selectedPaths.size === 0) return;
if(!confirm(`Permanently delete ${selectedPaths.size} selected photo(s) from disk?\n\nThis cannot be undone.`)) return;
$("btnBulkDelete").disabled = true;
const j = await api("/api/delete_photos", { paths: Array.from(selectedPaths) });
$("btnBulkDelete").disabled = false;
if(j.ok){
showToast(`Deleted ${j.deleted} photo(s) from disk`, "ok");
addLog({level:"ok", msg:`Batch deleted ${j.deleted} photos from disk.`, ts:new Date().toTimeString().slice(0,8)});
selectedPaths.clear();
updateBulkBar();
doSearch(true);
} else {
showToast(j.error || "Batch delete failed", "error");
}
};
}
$("btnDownloadSelected").onclick = ()=>{
if(selectedPaths.size === 0) return;