Files
OnlyFrames/index.html

423 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Foto-Kurator</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: system-ui, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 2rem 1rem; }
h1 { font-size: 2rem; margin-bottom: 0.25rem; color: #fff; }
.subtitle { color: #888; margin-bottom: 2rem; font-size: 0.95rem; }
.card { background: #16213e; border-radius: 12px; padding: 1.5rem; width: 100%; max-width: 640px; margin-bottom: 1.5rem; }
label { display: block; font-size: 0.85rem; color: #aaa; margin-bottom: 0.4rem; }
.folder-row { display: flex; gap: 0.5rem; align-items: stretch; }
.folder-row input[type="text"] { flex: 1; }
input[type="text"] { width: 100%; padding: 0.6rem 0.8rem; border-radius: 8px; border: 1px solid #333; background: #0f3460; color: #fff; font-size: 1rem; }
input[type="text"]:focus { outline: 2px solid #e94560; }
.pick-btn { padding: 0 1rem; border-radius: 8px; border: 1px solid #555; background: #0f3460; color: #aaa; cursor: pointer; font-size: 1.1rem; white-space: nowrap; transition: border-color 0.15s, color 0.15s; }
.pick-btn:hover { border-color: #e94560; color: #fff; }
button.primary { margin-top: 1rem; width: 100%; padding: 0.75rem; border-radius: 8px; border: none; background: #e94560; color: #fff; font-size: 1rem; font-weight: 600; cursor: pointer; }
button.primary:hover { background: #c73652; }
button.primary:disabled { background: #555; cursor: not-allowed; }
.toggle-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.toggle-label { font-size: 0.95rem; }
.toggle-note { font-size: 0.78rem; color: #888; margin-top: 0.2rem; }
.switch { position: relative; width: 44px; height: 24px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.knob { position: absolute; inset: 0; background: #333; border-radius: 24px; cursor: pointer; transition: background 0.2s; }
.knob::before { content: ""; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: transform 0.2s; }
input:checked + .knob { background: #e94560; }
input:checked + .knob::before { transform: translateX(20px); }
.slider-row { margin-bottom: 0.75rem; }
.slider-label { display: flex; justify-content: space-between; font-size: 0.85rem; color: #aaa; margin-bottom: 0.3rem; }
input[type="range"] { width: 100%; accent-color: #e94560; }
.view { display: none; }
.view.active { display: block; }
progress { width: 100%; height: 12px; border-radius: 6px; overflow: hidden; appearance: none; }
progress::-webkit-progress-bar { background: #0f3460; border-radius: 6px; }
progress::-webkit-progress-value { background: #e94560; border-radius: 6px; }
.progress-label { font-size: 0.85rem; color: #aaa; margin-top: 0.5rem; text-align: center; }
.photo-item { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid #222; }
.photo-item img { width: 64px; height: 64px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.photo-info { flex: 1; min-width: 0; }
.photo-name { font-size: 0.9rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.photo-reasons { font-size: 0.8rem; color: #e94560; margin-top: 0.2rem; }
.keep-btn { padding: 0.3rem 0.7rem; border-radius: 6px; border: 1px solid #555; background: transparent; color: #aaa; cursor: pointer; font-size: 0.8rem; flex-shrink: 0; }
.keep-btn:hover { border-color: #fff; color: #fff; }
.kept { opacity: 0.35; }
.kept .photo-name { text-decoration: line-through; }
.stat { display: flex; justify-content: space-between; padding: 0.5rem 0; border-bottom: 1px solid #222; font-size: 0.95rem; }
.stat-value { font-weight: 700; color: #e94560; }
.hint { margin-top: 1rem; color: #888; font-size: 0.85rem; }
.section-divider { font-size: 0.8rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #555; margin: 1.2rem 0 0.5rem; }
.badge-reject { font-size: 0.75rem; font-weight: 600; color: #e94560; background: rgba(233,69,96,0.12); border-radius: 4px; padding: 0.15rem 0.45rem; margin-left: 0.4rem; }
.badge-ok { font-size: 0.75rem; font-weight: 600; color: #4caf7d; background: rgba(76,175,125,0.12); border-radius: 4px; padding: 0.15rem 0.45rem; margin-left: 0.4rem; }
.photo-item img { cursor: zoom-in; }
/* Lightbox */
.lightbox { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.88); z-index: 1000; align-items: center; justify-content: center; }
.lightbox.open { display: flex; }
.lightbox img { max-width: 90vw; max-height: 90vh; border-radius: 8px; object-fit: contain; box-shadow: 0 8px 40px rgba(0,0,0,0.6); }
.lightbox-close { position: absolute; top: 1.2rem; right: 1.5rem; font-size: 2rem; color: #fff; cursor: pointer; line-height: 1; opacity: 0.7; }
.lightbox-close:hover { opacity: 1; }
.lightbox-name { position: absolute; bottom: 1.5rem; color: #ccc; font-size: 0.9rem; }
</style>
</head>
<body>
<h1>Foto-Kurator</h1>
<p class="subtitle">Automatisches Aussortieren von Fotos nach Qualitaet</p>
<!-- Start -->
<div id="view-start" class="view active card">
<label for="folder-input">Ordnerpfad</label>
<div class="folder-row">
<input type="text" id="folder-input" placeholder="/Users/name/Fotos/Shooting-2026" />
<button class="pick-btn" id="pick-btn" title="Ordner auswählen">&#128193;</button>
</div>
<div style="margin-top: 1.5rem;">
<div class="toggle-row">
<div>
<div class="toggle-label">Ueberpruefung vor dem Verschieben</div>
<div class="toggle-note">Zeigt aussortierte Fotos zur Bestaetigung an</div>
</div>
<label class="switch">
<input type="checkbox" id="toggle-review" checked>
<span class="knob"></span>
</label>
</div>
<div class="toggle-row">
<div>
<div class="toggle-label">KI-Analyse (Claude Vision)</div>
<div class="toggle-note">Genauer, aber ~0,003 EUR pro Foto &middot; Internetverbindung erforderlich</div>
</div>
<label class="switch">
<input type="checkbox" id="toggle-ai">
<span class="knob"></span>
</label>
</div>
</div>
<details style="margin-top: 1rem;">
<summary style="cursor: pointer; color: #aaa; font-size: 0.9rem; user-select: none;">Schwellenwerte anpassen</summary>
<div style="margin-top: 1rem;">
<div class="slider-row">
<div class="slider-label"><span>Unschaerfe-Grenze</span><span id="blur-val">100</span></div>
<input type="range" id="blur-threshold" min="10" max="500" value="100">
</div>
<div class="slider-row">
<div class="slider-label"><span>Ueberbelichtung (Helligkeit &gt;)</span><span id="over-val">240</span></div>
<input type="range" id="over-threshold" min="180" max="255" value="240">
</div>
<div class="slider-row">
<div class="slider-label"><span>Unterbelichtung (Helligkeit &lt;)</span><span id="under-val">30</span></div>
<input type="range" id="under-threshold" min="0" max="80" value="30">
</div>
<div class="slider-row">
<div class="slider-label"><span>Duplikat-Aehnlichkeit (pHash &le;)</span><span id="dup-val">8</span></div>
<input type="range" id="dup-threshold" min="0" max="20" value="8">
</div>
</div>
</details>
<button class="primary" id="start-btn">Analyse starten</button>
</div>
<!-- Progress -->
<div id="view-progress" class="view card">
<h2 style="margin-bottom: 1rem;">Analyse laeuft...</h2>
<progress id="progress-bar"></progress>
<p class="progress-label" id="progress-label">Vorbereitung...</p>
<button style="margin-top: 1rem; padding: 0.5rem 1rem; border-radius: 8px; border: 1px solid #555; background: transparent; color: #aaa; cursor: pointer;" id="cancel-btn">Abbrechen</button>
</div>
<!-- Review -->
<div id="view-review" class="view card">
<h2 style="margin-bottom: 0.5rem;">Vorschau aussortierter Fotos</h2>
<p style="color: #888; font-size: 0.85rem; margin-bottom: 1rem;">Klicke "Behalten", um ein Foto von der Liste zu entfernen.</p>
<div id="review-list"></div>
<button class="primary" id="confirm-btn">Alle bestaetigen &amp; verschieben</button>
</div>
<!-- Result -->
<div id="view-result" class="view card">
<h2 style="margin-bottom: 1rem;">Fertig!</h2>
<div id="result-stats"></div>
<button class="primary" id="restart-btn">Neuen Ordner analysieren</button>
</div>
<!-- Lightbox -->
<div class="lightbox" id="lightbox">
<span class="lightbox-close" id="lightbox-close">&times;</span>
<img id="lightbox-img" src="" alt="">
<span class="lightbox-name" id="lightbox-name"></span>
</div>
<script>
// --- State ---
let analysisResults = [];
let okPaths = [];
let currentFolder = "";
// --- Lightbox ---
function openLightbox(src, name) {
el("lightbox-img").src = src;
el("lightbox-name").textContent = name;
el("lightbox").classList.add("open");
}
el("lightbox-close").addEventListener("click", () => el("lightbox").classList.remove("open"));
el("lightbox").addEventListener("click", e => { if (e.target === el("lightbox")) el("lightbox").classList.remove("open"); });
// --- Helpers ---
function showView(id) {
document.querySelectorAll(".view").forEach(v => v.classList.remove("active"));
document.getElementById(id).classList.add("active");
}
function el(id) { return document.getElementById(id); }
// --- Slider labels ---
["blur", "over", "under", "dup"].forEach(key => {
const input = el(key + "-threshold");
const label = el(key + "-val");
input.addEventListener("input", () => { label.textContent = input.value; });
});
// --- Folder picker ---
el("pick-btn").addEventListener("click", async () => {
try {
const res = await fetch("/pick-folder");
if (res.status === 204) return;
const data = await res.json();
el("folder-input").value = data.folder;
} catch (e) {
alert("Ordner konnte nicht geöffnet werden: " + e.message);
}
});
// --- Cancel ---
el("cancel-btn").addEventListener("click", () => location.reload());
el("restart-btn").addEventListener("click", () => location.reload());
// --- Start Analysis ---
el("start-btn").addEventListener("click", async () => {
const folder = el("folder-input").value.trim();
if (!folder) { alert("Bitte einen Ordnerpfad eingeben."); return; }
currentFolder = folder;
showView("view-progress");
el("progress-label").textContent = "Analyse startet...";
const payload = {
folder,
blur_threshold: parseFloat(el("blur-threshold").value),
over_threshold: parseFloat(el("over-threshold").value),
under_threshold: parseFloat(el("under-threshold").value),
dup_threshold: parseInt(el("dup-threshold").value, 10),
use_ai: el("toggle-ai").checked,
};
let data;
try {
const res = await fetch("/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.detail || "Serverfehler");
}
data = await res.json();
} catch (e) {
alert("Fehler: " + e.message);
showView("view-start");
return;
}
analysisResults = data.results;
okPaths = data.ok_paths || [];
if (analysisResults.length === 0) {
renderResult(0);
return;
}
if (el("toggle-review").checked) {
renderReview();
showView("view-review");
} else {
await doMove();
}
});
// --- Review ---
function makeThumb(path, name) {
const img = document.createElement("img");
img.src = "/preview?path=" + encodeURIComponent(path);
img.alt = name;
img.onerror = function() { this.style.display = "none"; };
img.addEventListener("click", () => openLightbox(img.src, name));
return img;
}
function renderReview() {
const list = el("review-list");
list.textContent = "";
// --- Aussortierte Fotos ---
if (analysisResults.length > 0) {
const div = document.createElement("div");
div.className = "section-divider";
div.textContent = "Aussortieren (" + analysisResults.length + ")";
list.appendChild(div);
}
analysisResults.forEach((item, idx) => {
const name = item.path.split("/").pop();
const row = document.createElement("div");
row.className = "photo-item";
row.id = "item-" + idx;
const info = document.createElement("div");
info.className = "photo-info";
const nameEl = document.createElement("div");
nameEl.className = "photo-name";
nameEl.textContent = name;
const badge = document.createElement("span");
badge.className = "badge-reject";
badge.textContent = "aussortieren";
nameEl.appendChild(badge);
const reasonsEl = document.createElement("div");
reasonsEl.className = "photo-reasons";
reasonsEl.textContent = item.reasons.join(", ");
info.appendChild(nameEl);
info.appendChild(reasonsEl);
const btn = document.createElement("button");
btn.className = "keep-btn";
btn.textContent = "Behalten";
btn.addEventListener("click", () => {
row.classList.toggle("kept");
const isKept = row.classList.contains("kept");
btn.textContent = isKept ? "Aussortieren" : "Behalten";
badge.textContent = isKept ? "i.O." : "aussortieren";
badge.className = isKept ? "badge-ok" : "badge-reject";
});
row.appendChild(makeThumb(item.path, name));
row.appendChild(info);
row.appendChild(btn);
list.appendChild(row);
});
// --- i.O. Fotos ---
if (okPaths.length > 0) {
const div = document.createElement("div");
div.className = "section-divider";
div.textContent = "Behalten i.O. (" + okPaths.length + ")";
list.appendChild(div);
okPaths.forEach(path => {
const name = path.split("/").pop();
const row = document.createElement("div");
row.className = "photo-item";
const info = document.createElement("div");
info.className = "photo-info";
const nameEl = document.createElement("div");
nameEl.className = "photo-name";
nameEl.textContent = name;
const badge = document.createElement("span");
badge.className = "badge-ok";
badge.textContent = "i.O.";
nameEl.appendChild(badge);
info.appendChild(nameEl);
row.appendChild(makeThumb(path, name));
row.appendChild(info);
list.appendChild(row);
});
}
}
// --- Confirm & Move ---
el("confirm-btn").addEventListener("click", () => doMove(false));
async function doMove(skipReview = true) {
const toMove = skipReview
? analysisResults.map(r => r.path)
: analysisResults.filter((_, idx) => {
const row = el("item-" + idx);
return row && !row.classList.contains("kept");
}).map(r => r.path);
if (toMove.length === 0) {
renderResult(0);
return;
}
showView("view-progress");
el("progress-label").textContent = "Verschiebe " + toMove.length + " Fotos...";
try {
const res = await fetch("/move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths: toMove, folder: currentFolder }),
});
const data = await res.json();
renderResult(data.moved.length);
} catch (e) {
alert("Fehler beim Verschieben: " + e.message);
showView("view-review");
}
}
// --- Result ---
function renderResult(movedCount) {
const stats = el("result-stats");
stats.textContent = "";
const byReason = {};
analysisResults.forEach(item => {
item.reasons.forEach(r => { byReason[r] = (byReason[r] || 0) + 1; });
});
function addStat(label, value) {
const row = document.createElement("div");
row.className = "stat";
const l = document.createElement("span");
l.textContent = label;
const v = document.createElement("span");
v.className = "stat-value";
v.textContent = value;
row.appendChild(l);
row.appendChild(v);
stats.appendChild(row);
}
addStat("Analysierte Fotos", analysisResults.length + okPaths.length);
addStat("Behalten (i.O.)", okPaths.length);
addStat("Aussortiert", movedCount);
Object.entries(byReason).forEach(([reason, count]) => {
addStat(" davon: " + reason, count);
});
const hint = document.createElement("p");
hint.className = "hint";
hint.textContent = "Aussortierte Fotos befinden sich im Unterordner _aussortiert/";
stats.appendChild(hint);
showView("view-result");
}
</script>
</body>
</html>