Complete photo deletion on disk, snappy lightbox, keyboard shortcuts, prefetching, and perfected documentation
This commit is contained in:
98
index.html
98
index.html
@@ -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=>({"&":"&","<":"<",">":">"}[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;
|
||||
|
||||
Reference in New Issue
Block a user