This commit is contained in:
mike
2026-06-20 02:43:57 +02:00
parent f1f693523b
commit 0176c1b965
16 changed files with 3820 additions and 394 deletions

View File

@@ -649,6 +649,92 @@
#uploadInput { display: none; }
/* --- lightbox variant strip --- */
#lbVariantStrip {
display: none;
flex-direction: row;
gap: 6px;
overflow-x: auto;
padding: 6px 2px 2px;
width: 100%;
scrollbar-width: thin;
scrollbar-color: #444 transparent;
}
.lb-var-thumb {
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
cursor: pointer;
opacity: 0.5;
transition: opacity 0.15s;
max-width: 60px;
}
.lb-var-thumb:hover { opacity: 0.85; }
.lb-var-thumb.active { opacity: 1; outline: 2px solid #60a5fa; border-radius: 4px; }
.lb-var-thumb img {
width: 52px; height: 52px;
object-fit: cover;
border-radius: 4px;
display: block;
}
.lb-var-pose {
font-size: 7px;
color: #999;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 58px;
}
/* --- group card grid overview --- */
.card-grid {
position: absolute;
inset: 0;
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-rows: 1fr 1fr;
gap: 2px;
background: #111;
}
.card-grid-thumb {
position: relative;
overflow: hidden;
cursor: pointer;
}
.card-grid-thumb img {
width: 100%; height: 100%;
object-fit: cover;
display: block;
}
.card-grid-thumb .thumb-pose {
position: absolute;
bottom: 0; left: 0; right: 0;
background: rgba(0,0,0,0.72);
font-size: 7px;
color: #ccc;
padding: 1px 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
pointer-events: none;
}
.card-grid-more {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.55);
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
font-weight: 700;
color: #fff;
pointer-events: none;
}
/* --- privacy overlay --- */
.privacy-overlay {
display: none;
@@ -718,6 +804,7 @@
<button class="btn primary" onclick="refreshNow()">Refresh Now</button>
<button class="btn" id="selectBtn" onclick="toggleSelectMode()">Select</button>
<button class="btn" id="privacyBtn" onclick="togglePrivacyMode()" title="Privacy mode — hides content on focus loss (P)">🔓</button>
<button class="btn" onclick="cleanupDb()" title="Remove DB records for deleted/missing files" style="font-size:11px;opacity:0.6">Clean DB</button>
</div>
<div class="status">
<span class="status-dot" id="statusDot"></span>
@@ -764,11 +851,13 @@
<button class="lb-btn" id="lbMoveDown" style="font-size:13px;padding:3px 8px;display:none" onclick="lbMoveInGroup(1)" title="Move later in group"></button>
<button class="lb-btn" id="lbPreferredBtn" style="font-size:11px;padding:3px 8px;display:none;color:#f59e0b" onclick="lbSetPreferred()" title="Set as preferred reference for this group">★ Preferred</button>
<button class="lb-btn" id="lbEyeBtn" style="font-size:15px;padding:3px 8px" onclick="lbToggleHidden()" title="Hide/show this image in group cycling">👁</button>
<button class="lb-btn" style="font-size:11px;padding:3px 10px" onclick="tagCurrentImage()">Tag</button>
<button class="lb-btn" style="font-size:11px;padding:3px 10px" onclick="tagCurrentImage()" title="Re-run tagger to update tags and clothing detection">Re-tag</button>
<button class="lb-btn" id="lbUndressBtn" style="font-size:11px;padding:3px 10px;background:#7c3aed;display:none" onclick="lbUndress()" title="Generate without clothing">Undress</button>
<button class="lb-btn" id="lbExtract" style="font-size:11px;padding:3px 10px" onclick="extractCurrentImage()">Extract</button>
<button class="lb-btn" id="lbNoBgBtn" style="font-size:11px;padding:3px 10px;background: #334155" onclick="lbRemoveBg()">No BG</button>
<button class="lb-btn" style="font-size:11px;padding:3px 10px;background: #991b1b" onclick="lbDelete()">Delete</button>
<div id="lbSourceRefs" style="display:none;align-items:center;gap:4px;flex-wrap:wrap;margin-top:4px;width:100%"></div>
<div id="lbVariantStrip"></div>
</div>
</div>
@@ -1068,8 +1157,13 @@
let fileHidden = {}; // filename → boolean (hidden from cycling)
let fileHasBg = {}; // filename → boolean (has background, default true)
let fileSourceRefs = {}; // filename → array of source filenames
let fileHasClothing = {}; // filename → boolean|null (null = unknown)
let privacyMode = localStorage.getItem('privacyMode') === 'true';
function escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function getBaseName(name) {
return name.replace(/^(\d{8}_\d{6}_)+/, '');
}
@@ -1155,6 +1249,11 @@
updateLightbox();
}
function lbNavTo(idx) {
lbIdx = idx;
updateLightbox();
}
function updateLightbox() {
document.getElementById('lbImg').src = lbUrls[lbIdx];
const fname = lbNames[lbIdx];
@@ -1181,7 +1280,8 @@
document.getElementById('lbEyeBtn').title = hidden ? 'Show in cycling' : 'Hide from cycling';
// No BG button — hide when image already has no background
document.getElementById('lbNoBgBtn').style.display = fileHasBg[fname] !== false ? '' : 'none';
document.getElementById('lbNoBgBtn').style.display = fileHasBg[fname] !== false ? '' : 'none';
document.getElementById('lbUndressBtn').style.display = fileHasClothing[fname] === true ? '' : 'none';
// Source refs — show small clickable thumbnails
const refs = fileSourceRefs[fname];
@@ -1207,6 +1307,26 @@
// ★ Preferred — shown when not already the first image
const prefBtn = document.getElementById('lbPreferredBtn');
prefBtn.style.display = multi && lbIdx > 0 ? '' : 'none';
// Variant strip — thumbnails + pose labels for every image in the group
const strip = document.getElementById('lbVariantStrip');
if (multi) {
strip.style.display = 'flex';
strip.innerHTML = lbUrls.map((url, i) => {
const n = lbNames[i];
const pose = filePoses[n] || '';
const act = i === lbIdx ? ' active' : '';
return '<div class="lb-var-thumb' + act + '" onclick="lbNavTo(' + i + ')">'
+ '<img src="' + url + '" loading="lazy" onerror="this.style.opacity=\'0.3\'">'
+ (pose ? '<div class="lb-var-pose">' + escHtml(pose) + '</div>' : '')
+ '</div>';
}).join('');
// Scroll active thumb into view
const active = strip.querySelector('.lb-var-thumb.active');
if (active) active.scrollIntoView({ block: 'nearest', inline: 'center' });
} else {
strip.style.display = 'none';
}
}
async function updateName() {
@@ -1524,7 +1644,7 @@
// Primary: live list from API (always up to date, works after batch jobs)
try {
const r = await fetch(`${API}/images`, { signal: AbortSignal.timeout(3000) });
const r = await fetch(`${API}/images`, { signal: AbortSignal.timeout(12000) });
if (r.ok) {
const data = await r.json();
files = data.images.map(img => img.filename);
@@ -1537,7 +1657,8 @@
if (img.group_name && img.group_id) groupNames[img.group_id] = img.group_name;
fileSortOrders[img.filename] = img.sort_order ?? null;
fileHidden[img.filename] = !!img.hidden;
fileHasBg[img.filename] = img.has_background !== false;
fileHasBg[img.filename] = img.has_background !== false;
fileHasClothing[img.filename] = img.has_clothing ?? null;
if (img.source_refs) {
try { fileSourceRefs[img.filename] = JSON.parse(img.source_refs); } catch(e) {}
}
@@ -1570,16 +1691,19 @@
}
if (fileObjects.length === 0) {
gallery.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
<h2>No images found</h2>
<p>Place images in the output folder and refresh.</p>
</div>`;
// Only show "no images" if gallery is currently empty (avoid flash on slow API)
if (!gallery.querySelector('.image-card')) {
gallery.innerHTML = `
<div class="empty-state" style="grid-column: 1 / -1;">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<path d="M21 15l-5-5L5 21"/>
</svg>
<h2>No images found</h2>
<p>Place images in the output folder and refresh.</p>
</div>`;
}
statusDot.classList.remove('updating');
return;
}
@@ -1615,7 +1739,6 @@
const label = groupDisplayName(group);
const safeGid = gid.replace(/"/g, '&quot;');
const poses = [...new Set(gf.map(f => filePoses[f.cleanName]).filter(Boolean))];
// First visible image (fallback to first overall)
const firstVisUrl = visibleIdx.length ? urls[visibleIdx[0]] : urls[0];
return `
@@ -1635,22 +1758,20 @@
<div class="image-wrapper">
<div class="select-indicator"></div>
${vCount > 1 ? `<div class="variant-count">${vCount}×${count !== vCount ? `<span style="opacity:.5;font-size:9px"> /${count}</span>` : ''}</div>` : ''}
<img src="${firstVisUrl}" alt="${label}" loading="lazy"
onerror="this.style.opacity='0.2'">
<img src="${firstVisUrl}" alt="${label}" loading="${isRecent ? 'eager' : 'lazy'}" onerror="this.style.opacity='0.2'">
${vCount > 1 ? `<div class="variant-dots">${visibleIdx.map((_, i) => `<span class="dot${i === 0 ? ' active' : ''}"></span>`).join('')}</div>` : ''}
</div>
<div class="image-info">
<div class="image-name" title="${label}">${label}</div>
<div class="image-meta">
<span class="image-time">#${String(currentGroups.length - gIdx).padStart(3, '0')}</span>
${poses.length ? `<span class="badge" style="background:#78350f;font-size:9px">${poses[0]}</span>` : ''}
${poses.length ? `<span class="badge" style="background:#78350f;font-size:9px">${escHtml(poses[0])}</span>` : ''}
${isRecent ? '<span class="badge recent">Latest</span>' : ''}
</div>
</div>
</div>`;
}).join('');
// Start cycling for groups with 2+ visible images
currentGroups.forEach(g => {
const data = groupData.get(g.gid);
if (data && data.visibleIdx && data.visibleIdx.length > 1) startGroupCycle(g.gid);
@@ -2153,20 +2274,16 @@
});
});
// Privacy overlay — show when window loses focus, hide on regain
// Privacy overlay — show on any focus loss; requires explicit dismiss (click overlay or P)
window.addEventListener('blur', () => {
if (privacyMode) document.getElementById('privacyOverlay').style.display = 'flex';
});
window.addEventListener('focus', () => {
document.getElementById('privacyOverlay').style.display = 'none';
});
// Handle visibility change — refresh when tab becomes active; privacy on hide
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (privacyMode) document.getElementById('privacyOverlay').style.display = 'flex';
} else {
document.getElementById('privacyOverlay').style.display = 'none';
// Don't auto-hide — user must click overlay or press P to dismiss
loadImages();
}
});
@@ -2196,6 +2313,35 @@
} catch(e) { console.error(e); }
}
async function cleanupDb() {
try {
const r = await fetch(`${API}/db/cleanup`, { method: 'POST' });
const d = await r.json();
showToast(`Cleaned ${d.removed} orphan record${d.removed !== 1 ? 's' : ''}`);
if (d.removed > 0) refreshNow();
} catch(e) {
showToast('Cleanup failed');
}
}
async function lbUndress() {
const fname = lbNames[lbIdx];
if (!fname) return;
const btn = document.getElementById('lbUndressBtn');
btn.textContent = '...';
btn.disabled = true;
try {
const r = await fetch(`${API}/images/${encodeURIComponent(fname)}/undress`, {method:'POST'});
if (!r.ok) { showToast('Undress failed', 'error'); return; }
showToast('Undress queued', 'success');
} catch(e) {
showToast('Undress error', 'error');
} finally {
btn.textContent = 'Undress';
btn.disabled = false;
}
}
async function lbSetPreferred() {
const fname = lbNames[lbIdx];
if (!fname) return;