Files
OnlyFrames/index.html
T
ferdi2go 1aded7ff0d feat: auto feature detection with filename prefixes on export
Detects QR codes (QR_), barcodes (BC_), faces (FACE_/GROUP_),
and panoramas (PANO_) per photo using OpenCV — no new dependencies.
Opt-in checkboxes in the rename tab; prefixes prepend to filename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 12:50:34 +00:00

1655 lines
78 KiB
HTML

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>OnlyFrames</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><circle cx='16' cy='16' r='16' fill='%2300AFF0'/><text x='16' y='21' font-family='system-ui,sans-serif' font-size='12' font-weight='900' fill='white' text-anchor='middle'>OF</text></svg>">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Brand tokens ── */
:root {
--blue: #00AFF0;
--blue-dark: #0096CC;
--blue-light: #E6F7FE;
--blue-dim: rgba(0,175,240,0.10);
--bg: #F0F4F7;
--card: #FFFFFF;
--border: #E5E9ED;
--border-mid: #D0D5DB;
--text: #111827;
--muted: #6B7280;
--faint: #9CA3AF;
--red: #EF4444;
--red-dim: rgba(239,68,68,0.10);
--green: #22C55E;
--green-dim: rgba(34,197,94,0.10);
}
body { font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; align-items: center; padding: 2rem 1rem; }
/* ── Logo header ── */
.app-header { display: flex; flex-direction: column; align-items: center; margin-bottom: 2rem; }
.logo-row { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.3rem; }
.logo-badge { width: 44px; height: 44px; border-radius: 50%; background: linear-gradient(135deg, #00AFF0 0%, #0076B6 100%); color: #fff; font-weight: 900; font-size: 1rem; display: flex; align-items: center; justify-content: center; letter-spacing: -0.03em; box-shadow: 0 2px 12px rgba(0,175,240,0.35); flex-shrink: 0; }
h1 { font-size: 2rem; font-weight: 800; color: var(--text); letter-spacing: -0.03em; }
h1 span { color: var(--blue); }
.subtitle { color: var(--muted); font-size: 0.9rem; display: inline-block; }
.subtitle.pop { animation: slogan-pop 0.6s cubic-bezier(0.36, 0.07, 0.19, 0.97) both; }
@keyframes slogan-pop {
0% { transform: scale(0.85); opacity: 0; }
50% { transform: scale(1.12); opacity: 1; }
70% { transform: scale(0.96); }
85% { transform: scale(1.04); }
100% { transform: scale(1); opacity: 1; }
}
/* ── Cards ── */
.card { background: var(--card); border-radius: 14px; padding: 1.5rem; width: 100%; max-width: 640px; margin-bottom: 1.5rem; box-shadow: 0 1px 4px rgba(0,0,0,0.07), 0 4px 16px rgba(0,0,0,0.05); }
.card h2 { font-size: 1.15rem; font-weight: 700; margin-bottom: 0.4rem; color: var(--text); }
/* ── Form elements ── */
label { display: block; font-size: 0.82rem; font-weight: 500; color: var(--muted); 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: 1.5px solid var(--border); background: #fff; color: var(--text); font-size: 0.95rem; transition: border-color 0.15s; }
input[type="text"]:focus { outline: none; border-color: var(--blue); box-shadow: 0 0 0 3px var(--blue-dim); }
.pick-btn { padding: 0 1rem; border-radius: 8px; border: 1.5px solid var(--border); background: #fff; color: var(--muted); cursor: pointer; font-size: 1.1rem; white-space: nowrap; transition: border-color 0.15s, color 0.15s; }
.pick-btn:hover { border-color: var(--blue); color: var(--blue); }
button.primary { margin-top: 1rem; width: 100%; padding: 0.75rem; border-radius: 9px; border: none; background: linear-gradient(135deg, var(--blue) 0%, var(--blue-dark) 100%); color: #fff; font-size: 1rem; font-weight: 700; cursor: pointer; letter-spacing: 0.01em; box-shadow: 0 2px 8px rgba(0,175,240,0.3); transition: filter 0.15s, box-shadow 0.15s; }
button.primary:hover { filter: brightness(1.07); box-shadow: 0 4px 16px rgba(0,175,240,0.35); }
button.primary:disabled { background: var(--border); box-shadow: none; cursor: not-allowed; color: var(--faint); }
/* ── Toggles ── */
.toggle-section { margin-top: 1.25rem; border-top: 1px solid var(--border); padding-top: 1.25rem; display: flex; flex-direction: column; gap: 0.85rem; }
.toggle-row { display: flex; justify-content: space-between; align-items: center; }
.toggle-label { font-size: 0.92rem; font-weight: 500; color: var(--text); }
.toggle-note { font-size: 0.76rem; color: var(--faint); margin-top: 0.15rem; }
.switch { position: relative; width: 44px; height: 24px; flex-shrink: 0; }
.switch input { opacity: 0; width: 0; height: 0; }
.knob { position: absolute; inset: 0; background: var(--border-mid); 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; box-shadow: 0 1px 3px rgba(0,0,0,0.2); }
input:checked + .knob { background: var(--blue); }
input:checked + .knob::before { transform: translateX(20px); }
/* ── Sliders ── */
.slider-row { margin-bottom: 0.75rem; }
.slider-label { display: flex; justify-content: space-between; font-size: 0.82rem; color: var(--muted); margin-bottom: 0.3rem; }
input[type="range"] { width: 100%; accent-color: var(--blue); }
select { width: 100%; padding: 0.4rem 0.6rem; border-radius: 8px; border: 1.5px solid var(--border); background: #fff; color: var(--text); font-size: 0.9rem; cursor: pointer; }
select:focus { outline: none; border-color: var(--blue); }
details summary { cursor: pointer; color: var(--muted); font-size: 0.88rem; user-select: none; padding: 0.5rem 0; }
/* ── Views ── */
.view { display: none; }
.view.active { display: block; }
/* ── Progress ── */
progress { width: 100%; height: 10px; border-radius: 5px; overflow: hidden; appearance: none; }
progress::-webkit-progress-bar { background: var(--bg); border-radius: 5px; }
progress::-webkit-progress-value { background: linear-gradient(90deg, var(--blue), var(--blue-dark)); border-radius: 5px; transition: width 0.4s ease; }
progress:indeterminate { background: linear-gradient(90deg, var(--bg) 25%, var(--blue) 50%, var(--bg) 75%); background-size: 200% 100%; animation: progress-slide 1.4s infinite linear; }
@keyframes progress-slide { from { background-position: 100% 0; } to { background-position: -100% 0; } }
.progress-word-wrap { overflow: hidden; text-align: center; margin-top: 0.9rem; height: 1.6em; }
.progress-word { display: inline-block; font-size: 1rem; font-weight: 600; color: var(--blue); letter-spacing: 0.01em; }
.progress-word.slide-out-left { animation: slideOutLeft 0.35s ease forwards; }
.progress-word.slide-in-right { animation: slideInRight 0.35s ease forwards; }
@keyframes slideOutLeft { to { transform: translateX(-110%); opacity: 0; } }
@keyframes slideInRight { from { transform: translateX( 110%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
.progress-label { font-size: 0.8rem; color: var(--faint); margin-top: 0.35rem; text-align: center; min-height: 1.2em; }
/* ── Review list ── */
.photo-item { display: flex; align-items: center; gap: 1rem; padding: 0.75rem 0; border-bottom: 1px solid var(--border); }
.photo-item img { width: 64px; height: 64px; object-fit: cover; border-radius: 8px; flex-shrink: 0; cursor: zoom-in; }
.photo-info { flex: 1; min-width: 0; }
.photo-name { font-size: 0.88rem; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text); }
.photo-reasons { font-size: 0.78rem; color: var(--red); margin-top: 0.2rem; }
.keep-btn { padding: 0.3rem 0.7rem; border-radius: 6px; border: 1.5px solid var(--border); background: transparent; color: var(--muted); cursor: pointer; font-size: 0.78rem; flex-shrink: 0; transition: border-color 0.15s, color 0.15s; }
.keep-btn:hover { border-color: var(--blue); color: var(--blue); }
.kept { opacity: 0.4; }
.kept .photo-name { text-decoration: line-through; }
/* ── Stats ── */
.stat { display: flex; justify-content: space-between; padding: 0.6rem 0; border-bottom: 1px solid var(--border); font-size: 0.95rem; }
.stat-value { font-weight: 700; color: var(--blue); }
.hint { margin-top: 1rem; color: var(--faint); font-size: 0.84rem; }
/* ── Badges ── */
.badge-reject { font-size: 0.72rem; font-weight: 700; color: var(--red); background: var(--red-dim); border-radius: 4px; padding: 0.15rem 0.45rem; margin-left: 0.4rem; }
.badge-ok { font-size: 0.72rem; font-weight: 700; color: var(--green); background: var(--green-dim); border-radius: 4px; padding: 0.15rem 0.45rem; margin-left: 0.4rem; }
/* ── 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: 10px; object-fit: contain; box-shadow: 0 8px 40px rgba(0,0,0,0.5); }
.lightbox-close { position: absolute; top: 1.2rem; right: 1.5rem; font-size: 2rem; color: #fff; cursor: pointer; opacity: 0.7; }
.lightbox-close:hover { opacity: 1; }
.lightbox-name { position: absolute; bottom: 1.5rem; color: #ccc; font-size: 0.88rem; }
/* ── Drop Zone ── */
.drop-zone { border: 2px dashed var(--border-mid); border-radius: 12px; padding: 2rem 1rem; text-align: center; transition: border-color 0.15s, background 0.15s; margin-bottom: 1rem; cursor: default; }
.drop-zone.drag-over { border-color: var(--blue); background: var(--blue-dim); }
.drop-zone.done { border-color: var(--green); background: var(--green-dim); }
.drop-icon { margin-bottom: 0.5rem; line-height: 1; }
.drop-text { font-size: 1rem; font-weight: 500; color: var(--text); margin-bottom: 0.25rem; }
.drop-sub { font-size: 0.8rem; color: var(--faint); }
.upload-status { margin-bottom: 1rem; }
.upload-bar-wrap { height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; margin-bottom: 0.4rem; }
.upload-bar { height: 100%; background: linear-gradient(90deg, var(--blue), var(--blue-dark)); border-radius: 3px; width: 0%; transition: width 0.2s; }
.upload-label { font-size: 0.82rem; color: var(--muted); text-align: center; }
/* ── Folder Browser Modal ── */
.fb-backdrop { display: none; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 2000; align-items: center; justify-content: center; }
.fb-backdrop.open { display: flex; }
.fb-modal { background: var(--card); border-radius: 14px; width: 92%; max-width: 520px; max-height: 80vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 16px 48px rgba(0,0,0,0.18); }
.fb-header { padding: 1rem 1.2rem 0.75rem; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 0.3rem; }
.fb-title { font-weight: 700; font-size: 1rem; color: var(--text); }
.fb-path { font-size: 0.76rem; color: var(--faint); word-break: break-all; }
.fb-list { overflow-y: auto; flex: 1; padding: 0.4rem 0; }
.fb-item { display: flex; align-items: center; gap: 0.6rem; padding: 0.55rem 1.2rem; cursor: pointer; font-size: 0.9rem; color: var(--text); transition: background 0.1s; }
.fb-item:hover { background: var(--blue-dim); color: var(--blue); }
.fb-item-up { color: var(--muted); }
.fb-footer { padding: 0.75rem 1.2rem; border-top: 1px solid var(--border); display: flex; gap: 0.6rem; justify-content: flex-end; }
.fb-select-btn { padding: 0.5rem 1.2rem; border-radius: 8px; border: none; background: var(--blue); color: #fff; font-weight: 700; cursor: pointer; font-size: 0.9rem; }
.fb-select-btn:hover { background: var(--blue-dark); }
.fb-cancel-btn { padding: 0.5rem 1rem; border-radius: 8px; border: 1.5px solid var(--border); background: transparent; color: var(--muted); cursor: pointer; font-size: 0.9rem; }
.fb-cancel-btn:hover { border-color: var(--blue); color: var(--blue); }
.fb-empty { padding: 1.5rem 1.2rem; color: var(--faint); font-size: 0.9rem; }
/* ── Tinder Mode ── */
#view-tinder { width: 100%; max-width: 480px; }
.tinder-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; }
.tinder-counter { font-size: 0.92rem; color: var(--muted); font-weight: 500; }
.tinder-done-btn { padding: 0.3rem 0.8rem; border-radius: 6px; border: 1.5px solid var(--border); background: #fff; color: var(--muted); cursor: pointer; font-size: 0.82rem; transition: border-color 0.15s, color 0.15s; }
.tinder-done-btn:hover { border-color: var(--blue); color: var(--blue); }
.tinder-card { position: relative; border-radius: 16px; overflow: hidden; background: #fff; box-shadow: 0 4px 24px rgba(0,0,0,0.12); transform-origin: bottom center; touch-action: none; user-select: none; }
.tinder-card.snapping { transition: transform 0.25s cubic-bezier(0.2,0,0.4,1); }
.tinder-card img { width: 100%; max-height: 62vh; object-fit: contain; background: #F8F9FB; display: block; }
.tinder-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 3rem; font-weight: 900; letter-spacing: 0.08em; opacity: 0; pointer-events: none; transition: opacity 0.12s; }
.tinder-overlay-ok { background: rgba(34,197,94,0.25); color: var(--green); }
.tinder-overlay-nok { background: rgba(239,68,68,0.25); color: var(--red); }
.tinder-info { padding: 0.75rem 1rem; display: flex; justify-content: space-between; align-items: center; gap: 0.5rem; min-width: 0; background: #fff; }
.tinder-name { font-size: 0.85rem; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.tinder-hints { display: flex; justify-content: space-between; margin: 0.75rem 0.25rem 0; font-size: 0.84rem; font-weight: 500; }
.tinder-hint-nok { color: var(--red); }
.tinder-hint-ok { color: var(--green); }
.tinder-btn-row { display: flex; gap: 1rem; margin-top: 1rem; }
.tinder-btn { flex: 1; padding: 0.85rem; border-radius: 12px; border: 2px solid; background: transparent; font-size: 1.6rem; cursor: pointer; transition: background 0.15s, transform 0.1s; }
.tinder-btn:active { transform: scale(0.94); }
.tinder-btn-nok { border-color: var(--red); color: var(--red); }
.tinder-btn-nok:hover { background: var(--red-dim); }
.tinder-btn-ok { border-color: var(--green); color: var(--green); }
.tinder-btn-ok:hover { background: var(--green-dim); }
@keyframes flyLeft { to { transform: translateX(-130%) rotate(-22deg); opacity: 0; } }
@keyframes flyRight { to { transform: translateX(130%) rotate(22deg); opacity: 0; } }
@keyframes flyUp { to { transform: translateY(-110%) scale(0.85); opacity: 0; } }
@keyframes dropIn { from { transform: translateY(-60px) scale(0.95); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes backFromLeft { from { transform: translateX(-130%) rotate(-22deg); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes backFromRight{ from { transform: translateX(130%) rotate(22deg); opacity: 0; } to { transform: none; opacity: 1; } }
@keyframes backFromTop { from { transform: translateY(-110%) scale(0.85); opacity: 0; } to { transform: none; opacity: 1; } }
.fly-left { animation: flyLeft 0.32s cubic-bezier(0.4,0,1,1) forwards; }
.fly-right { animation: flyRight 0.32s cubic-bezier(0.4,0,1,1) forwards; }
.fly-up { animation: flyUp 0.30s cubic-bezier(0.4,0,1,1) forwards; }
.drop-in { animation: dropIn 0.28s cubic-bezier(0.2,0,0.5,1) forwards; }
.back-from-left { animation: backFromLeft 0.32s cubic-bezier(0,0,0.4,1) forwards; }
.back-from-right { animation: backFromRight 0.32s cubic-bezier(0,0,0.4,1) forwards; }
.back-from-top { animation: backFromTop 0.30s cubic-bezier(0,0,0.4,1) forwards; }
/* Favorites overlay */
.tinder-overlay-fav { background: rgba(251,191,36,0.25); color: #F59E0B; font-size: 2rem; }
/* Undo / Fav buttons */
.tinder-fav-star { position: absolute; top: 10px; right: 10px; background: none; border: none; font-size: 1.6rem; line-height: 1; cursor: pointer; color: rgba(255,255,255,0.75); text-shadow: 0 1px 4px rgba(0,0,0,0.4); transition: color 0.15s; z-index: 3; padding: 0.15rem 0.2rem; }
.tinder-fav-star:hover { color: #F59E0B; }
.tinder-fav-star.active { color: #F59E0B; text-shadow: 0 0 10px rgba(245,158,11,0.7), 0 1px 4px rgba(0,0,0,0.3); }
@keyframes starBounce { 0%{transform:scale(1)} 30%{transform:scale(1.7)} 55%{transform:scale(0.85)} 75%{transform:scale(1.2)} 100%{transform:scale(1)} }
.tinder-fav-star.bounce { animation: starBounce 0.45s cubic-bezier(0.36,0.07,0.19,0.97); }
/* Export tabs */
.export-opts { margin-top: 1rem; border-top: 1px solid var(--border); padding-top: 1rem; }
.export-opts summary { cursor: pointer; font-size: 0.88rem; font-weight: 600; color: var(--blue); padding: 0.3rem 0; user-select: none; }
.export-tabs { display: flex; gap: 0; margin: 0.75rem 0 0; border-bottom: 2px solid var(--border); }
.export-tab { padding: 0.4rem 0.9rem; border: none; background: transparent; color: var(--muted); font-size: 0.83rem; font-weight: 500; cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -2px; transition: color 0.15s, border-color 0.15s; }
.export-tab:hover { color: var(--blue); }
.export-tab.active { color: var(--blue); border-bottom-color: var(--blue); }
.export-panel { padding-top: 0.75rem; display: none; }
.export-panel.active { display: block; }
.exp-row { display: flex; flex-direction: column; gap: 0.25rem; margin-bottom: 0.75rem; }
.exp-row label { font-size: 0.8rem; color: var(--muted); font-weight: 500; }
.exp-row input[type=text], .exp-row select { padding: 0.35rem 0.6rem; border: 1.5px solid var(--border); border-radius: 7px; font-size: 0.85rem; background: var(--bg); color: var(--text); width: 100%; }
.exp-row input[type=text]:focus, .exp-row select:focus { border-color: var(--blue); outline: none; }
.exp-slider-row { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.6rem; }
.exp-slider-label { font-size: 0.8rem; color: var(--muted); width: 80px; flex-shrink: 0; }
.exp-slider-val { font-size: 0.8rem; color: var(--text); width: 38px; text-align: right; flex-shrink: 0; font-variant-numeric: tabular-nums; }
.exp-slider-row input[type=range] { flex: 1; }
.wm-sub-tabs { display: flex; gap: 0.4rem; margin-bottom: 0.75rem; }
.wm-sub { padding: 0.3rem 0.8rem; border-radius: 6px; border: 1.5px solid var(--border); background: transparent; color: var(--muted); font-size: 0.82rem; cursor: pointer; transition: border-color 0.15s, color 0.15s; }
.wm-sub.active { border-color: var(--blue); color: var(--blue); }
.hint-row { font-size: 0.75rem; color: var(--faint); margin-top: 0.2rem; }
.feat-checks { display: flex; flex-wrap: wrap; gap: 0.4rem 0.75rem; margin-top: 0.35rem; }
.feat-check { display: flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; color: var(--text); cursor: pointer; }
.feat-check input[type=checkbox] { accent-color: var(--blue); width: 14px; height: 14px; cursor: pointer; }
/* ── Ordner-Auswahl-Button in Drop-Zone ── */
#folder-picker-btn:hover { border-color: var(--blue); color: var(--blue); }
/* ── Upload-Übersicht ── */
.uploads-card { width: 100%; max-width: 640px; margin-top: auto; padding-top: 2rem; margin-bottom: 1.5rem; }
.uploads-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.75rem; }
.uploads-title { font-size: 0.82rem; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: 0.05em; }
.uploads-refresh { background: none; border: none; color: var(--faint); cursor: pointer; font-size: 0.9rem; padding: 0.1rem 0.3rem; border-radius: 4px; }
.uploads-refresh:hover { color: var(--blue); }
.upload-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0.9rem; background: var(--card); border-radius: 10px; margin-bottom: 0.5rem; box-shadow: 0 1px 4px rgba(0,0,0,0.06); cursor: pointer; transition: box-shadow 0.15s, background 0.15s; }
.upload-row:hover { background: #f0f7fd; box-shadow: 0 2px 8px rgba(0,175,240,0.12); }
.upload-row-active { border: 1.5px solid var(--blue); background: #e8f6fd; }
.upload-row-active:hover { background: #dff1fb; }
.upload-row-icon { font-size: 1.3rem; flex-shrink: 0; }
.upload-row-info { flex: 1; min-width: 0; }
.upload-row-name { font-size: 0.85rem; font-weight: 500; color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.upload-row-meta { font-size: 0.75rem; color: var(--faint); margin-top: 0.1rem; }
.upload-row-del { background: none; border: none; font-size: 1.1rem; cursor: pointer; color: var(--faint); padding: 0.2rem 0.35rem; border-radius: 6px; flex-shrink: 0; transition: color 0.15s, background 0.15s; }
.upload-row-del:hover { color: var(--red); background: var(--red-dim); }
.uploads-empty { font-size: 0.85rem; color: var(--faint); text-align: center; padding: 0.75rem; }
/* ── Zurück-Button ── */
.back-btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.3rem 0.75rem; border-radius: 7px; border: 1.5px solid var(--border); background: transparent; color: var(--muted); cursor: pointer; font-size: 0.82rem; font-weight: 500; transition: border-color 0.15s, color 0.15s; margin-bottom: 1rem; }
.back-btn:hover { border-color: var(--blue); color: var(--blue); }
.view-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
.view-header h2 { margin-bottom: 0; }
</style>
</head>
<body>
<div class="app-header">
<div class="logo-row">
<div class="logo-badge">OF</div>
<h1>Only<span>Frames</span></h1>
</div>
<p class="subtitle" id="app-slogan"></p>
</div>
<!-- Login -->
<div id="view-login" class="view card" style="max-width:360px; text-align:center;">
<div class="logo-badge" style="margin:0 auto 1rem;">OF</div>
<h2 style="margin-bottom:0.25rem;">Zugang</h2>
<p style="color:var(--muted); font-size:0.85rem; margin-bottom:1.25rem;">Bitte Passwort eingeben</p>
<input type="password" id="login-pw" placeholder="Passwort"
style="width:100%; padding:0.55rem 0.8rem; border:1.5px solid var(--border); border-radius:9px;
font-size:1rem; margin-bottom:0.75rem; box-sizing:border-box; text-align:center; letter-spacing:0.15em;">
<button class="primary" id="login-btn">Anmelden</button>
<p id="login-error" style="color:var(--red); font-size:0.83rem; margin-top:0.6rem; min-height:1.1em;"></p>
</div>
<!-- Start -->
<div id="view-start" class="view active card">
<input type="file" id="folder-picker" webkitdirectory multiple accept="image/*" style="display:none">
<div id="drop-zone" class="drop-zone">
<div class="drop-icon">
<svg width="52" height="52" viewBox="0 0 52 52" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="4" y="4" width="44" height="44" rx="10" fill="var(--blue-dim)" stroke="var(--blue)" stroke-width="2"/>
<rect x="13" y="16" width="26" height="20" rx="4" fill="none" stroke="var(--blue)" stroke-width="2"/>
<line x1="19" y1="16" x2="19" y2="12" stroke="var(--blue)" stroke-width="2" stroke-linecap="round"/>
<line x1="19" y1="12" x2="28" y2="12" stroke="var(--blue)" stroke-width="2" stroke-linecap="round"/>
<line x1="28" y1="12" x2="28" y2="16" stroke="var(--blue)" stroke-width="2" stroke-linecap="round"/>
<line x1="26" y1="22" x2="26" y2="30" stroke="var(--blue)" stroke-width="2" stroke-linecap="round"/>
<line x1="22" y1="26" x2="30" y2="26" stroke="var(--blue)" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<p class="drop-text">Drag &amp; Drop</p>
<p style="font-size:0.8rem; color:var(--faint); margin:0.4rem 0;">oder</p>
<button id="folder-picker-btn" style="padding:0.45rem 1.1rem; border-radius:8px; border:1.5px solid var(--border); background:#fff; color:var(--muted); font-size:0.85rem; font-weight:500; cursor:pointer; transition:border-color 0.15s, color 0.15s;">Ordner auswählen</button>
<p id="drop-sub" style="font-size:0.8rem; color:var(--faint); margin-top:0.6rem; min-height:1.1em;"></p>
</div>
<div id="upload-status" class="upload-status" style="display:none">
<div class="upload-bar-wrap"><div id="upload-bar" class="upload-bar"></div></div>
<p id="upload-label" class="upload-label">Lade hoch...</p>
</div>
<input type="hidden" id="folder-input" />
<div class="toggle-section">
<div class="toggle-row">
<div>
<div class="toggle-label">Überprüfung vor dem Verschieben</div>
<div class="toggle-note">Zeigt aussortierte Fotos zur Bestätigung 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">Dating-Modus</div>
<div class="toggle-note">Fotos einzeln per Pfeiltaste oder Button durchgehen</div>
</div>
<label class="switch">
<input type="checkbox" id="toggle-tinder" 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>Schwellenwerte anpassen</summary>
<div style="margin-top: 1rem;">
<div class="slider-row">
<div class="slider-label"><span>Preset</span></div>
<select id="preset-select">
<option value="">— Preset wählen —</option>
<option value="standard">Standard</option>
<option value="outdoor">Draußen</option>
<option value="night">Nacht</option>
<option value="portrait">Portrait</option>
<option value="nature">Natur</option>
<option value="studio">Studio</option>
</select>
</div>
<div class="slider-row">
<div class="slider-label"><span>Unschärfe-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>Überbelichtung (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-Ähnlichkeit (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">
<div class="view-header">
<h2>Analyse läuft…</h2>
<button class="back-btn" id="cancel-btn">← Abbrechen</button>
</div>
<progress id="progress-bar"></progress>
<div class="progress-word-wrap">
<span class="progress-word" id="progress-word">Grübelt…</span>
</div>
<p class="progress-label" id="progress-label"></p>
</div>
<!-- Review -->
<div id="view-review" class="view card">
<div class="view-header">
<h2>Sortierübersicht</h2>
<button class="back-btn" id="review-back-btn">← Zurück</button>
</div>
<div class="toggle-row" style="margin-bottom: 1rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border);">
<div>
<div class="toggle-label">Dating-Modus</div>
<div class="toggle-note">Fotos einzeln durchswipen statt Liste</div>
</div>
<label class="switch">
<input type="checkbox" id="toggle-tinder-inline">
<span class="knob"></span>
</label>
</div>
<p style="color: var(--muted); font-size: 0.85rem; margin-bottom: 1rem;">Alle Fotos auf einen Blick — klicke auf ein Vorschaubild zum Vergrößern oder ändere die Entscheidung per Button.</p>
<div id="review-list"></div>
<button class="primary" id="confirm-btn">Alle bestätigen &amp; verschieben</button>
</div>
<!-- Result -->
<div id="view-result" class="view card">
<div class="view-header">
<h2>Fertig!</h2>
<button class="back-btn" id="result-back-btn">← Zurück zur Startseite</button>
</div>
<div id="result-stats"></div>
<!-- Schnell-Download (ohne Verarbeitung) -->
<a class="primary" id="download-btn" style="display:none; text-decoration:none; text-align:center;">⬇ Schnell-Download (Original)</a>
<!-- Exportoptionen -->
<details class="export-opts" id="export-opts">
<summary>⚙ Exportoptionen — Umbenennen · Bildeditor · Wasserzeichen</summary>
<div class="export-tabs">
<button class="export-tab active" data-tab="rename">Umbenennen</button>
<button class="export-tab" data-tab="editor">Bildeditor</button>
<button class="export-tab" data-tab="watermark">Wasserzeichen</button>
</div>
<!-- Tab: Umbenennen -->
<div class="export-panel active" id="tab-rename">
<div class="exp-row">
<label>Dateinamen-Schema</label>
<select id="rename-mode">
<option value="original">Original (unverändert)</option>
<option value="datetime">Datum + Zeit aus EXIF</option>
<option value="date_seq">Datum + Sequenz</option>
<option value="prefix_seq">Präfix + Sequenz</option>
</select>
</div>
<div class="exp-row" id="rename-prefix-row" style="display:none">
<label>Präfix</label>
<input type="text" id="rename-prefix" placeholder="z.B. Urlaub2024_">
</div>
<div class="exp-row">
<label>Favoriten-Präfix</label>
<input type="text" id="fav-prefix" value="FAV_" placeholder="FAV_">
<span class="hint-row">Favorisierte Fotos erhalten dieses Präfix im Dateinamen</span>
</div>
<div class="exp-row" style="margin-top:0.75rem; border-top:1px solid var(--border); padding-top:0.75rem;">
<label>Merkmale automatisch erkennen &amp; als Präfix setzen</label>
<div class="feat-checks">
<label class="feat-check"><input type="checkbox" id="feat-qr"> QR_</label>
<label class="feat-check"><input type="checkbox" id="feat-bc"> BC_</label>
<label class="feat-check"><input type="checkbox" id="feat-face"> FACE_ / GROUP_</label>
<label class="feat-check"><input type="checkbox" id="feat-pano"> PANO_</label>
</div>
<span class="hint-row">Wird beim Export pro Foto erkannt — verlangsamt den Export leicht</span>
</div>
</div>
<!-- Tab: Bildeditor -->
<div class="export-panel" id="tab-editor">
<div class="exp-slider-row">
<span class="exp-slider-label">Drehung</span>
<input type="range" id="exp-rotation" min="-45" max="45" step="0.5" value="0">
<span class="exp-slider-val" id="exp-rotation-val"></span>
</div>
<button class="back-btn" id="auto-detect-angle" style="margin-bottom:0.3rem; font-size:0.82rem;">↗ Horizont auto-erkennen</button>
<p class="hint-row" style="margin-bottom:0.75rem;">Winkel wird am ersten Foto gemessen — bei Bedarf manuell anpassen</p>
<div class="exp-slider-row">
<span class="exp-slider-label">Helligkeit</span>
<input type="range" id="exp-brightness" min="0.2" max="2.0" step="0.05" value="1.0">
<span class="exp-slider-val" id="exp-brightness-val">1.0</span>
</div>
<div class="exp-slider-row">
<span class="exp-slider-label">Kontrast</span>
<input type="range" id="exp-contrast" min="0.2" max="2.0" step="0.05" value="1.0">
<span class="exp-slider-val" id="exp-contrast-val">1.0</span>
</div>
<div class="exp-slider-row">
<span class="exp-slider-label">Sättigung</span>
<input type="range" id="exp-saturation" min="0.0" max="2.0" step="0.05" value="1.0">
<span class="exp-slider-val" id="exp-saturation-val">1.0</span>
</div>
</div>
<!-- Tab: Wasserzeichen -->
<div class="export-panel" id="tab-watermark">
<div class="wm-sub-tabs">
<button class="wm-sub active" id="wm-sub-text">Text</button>
<button class="wm-sub" id="wm-sub-image">Bild</button>
</div>
<div id="wm-text-panel">
<div class="exp-row">
<label>Text <span class="hint-row">(Variablen: {date} {time} {camera} {lens})</span></label>
<input type="text" id="wm-text" placeholder="© 2024 Mein Name · {date}">
</div>
<div class="exp-row">
<label>Position</label>
<select id="wm-text-pos">
<option value="br">Unten rechts</option>
<option value="bl">Unten links</option>
<option value="tr">Oben rechts</option>
<option value="tl">Oben links</option>
<option value="bc">Unten Mitte</option>
<option value="center">Mitte</option>
</select>
</div>
<div class="exp-slider-row">
<span class="exp-slider-label">Schriftgröße</span>
<input type="range" id="wm-font-size" min="8" max="120" value="32">
<span class="exp-slider-val" id="wm-font-size-val">32</span>
</div>
<div class="exp-slider-row">
<span class="exp-slider-label">Deckkraft</span>
<input type="range" id="wm-text-opacity" min="0.1" max="1.0" step="0.05" value="0.75">
<span class="exp-slider-val" id="wm-text-opacity-val">0.75</span>
</div>
</div>
<div id="wm-image-panel" style="display:none">
<div class="exp-row">
<label>Wasserzeichen-Bild (PNG mit Transparenz empfohlen)</label>
<input type="file" id="wm-image-upload" accept="image/*" style="margin-top:0.3rem; font-size:0.83rem;">
<span class="hint-row" id="wm-image-status"></span>
</div>
<div class="exp-row">
<label>Position</label>
<select id="wm-image-pos">
<option value="br">Unten rechts</option>
<option value="bl">Unten links</option>
<option value="tr">Oben rechts</option>
<option value="tl">Oben links</option>
<option value="bc">Unten Mitte</option>
<option value="center">Mitte</option>
</select>
</div>
<div class="exp-slider-row">
<span class="exp-slider-label">Deckkraft</span>
<input type="range" id="wm-image-opacity" min="0.1" max="1.0" step="0.05" value="0.6">
<span class="exp-slider-val" id="wm-image-opacity-val">0.6</span>
</div>
<div class="exp-slider-row">
<span class="exp-slider-label">Größe (%)</span>
<input type="range" id="wm-image-scale" min="5" max="60" value="20">
<span class="exp-slider-val" id="wm-image-scale-val">20</span>
</div>
</div>
</div>
<p class="hint-row" style="margin-top:1rem; margin-bottom:0.4rem;">Alle Fotos werden als JPEG exportiert — PNG-Transparenz geht dabei verloren</p>
<button class="primary" id="export-btn">⬇ Exportieren &amp; ZIP herunterladen</button>
<div id="export-progress" style="display:none; margin-top:0.75rem;">
<progress id="export-bar"></progress>
<p class="progress-label" id="export-label"></p>
</div>
</details>
<button class="primary" id="restart-btn" style="margin-top:0.75rem; background:transparent; border:1.5px solid var(--border); color:var(--muted); box-shadow:none;">Neuen Ordner analysieren</button>
</div>
<!-- Upload-Übersicht -->
<div class="uploads-card" id="uploads-card">
<div class="uploads-header">
<span class="uploads-title">Gespeicherte Uploads</span>
<button class="uploads-refresh" id="uploads-refresh" title="Aktualisieren"></button>
</div>
<div id="uploads-list"></div>
</div>
<!-- Folder Browser Modal -->
<div class="fb-backdrop" id="fb-backdrop">
<div class="fb-modal" role="dialog" aria-modal="true" aria-label="Ordner auswählen">
<div class="fb-header">
<span class="fb-title">Ordner auswählen</span>
<span class="fb-path" id="fb-path"></span>
</div>
<div class="fb-list" id="fb-list"></div>
<div class="fb-footer">
<button class="fb-cancel-btn" id="fb-cancel">Abbrechen</button>
<button class="fb-select-btn" id="fb-select">Diesen Ordner wählen</button>
</div>
</div>
</div>
<!-- Tinder -->
<div id="view-tinder" class="view">
<div class="card" style="padding:1rem;">
<div class="tinder-header">
<button class="back-btn" id="tinder-back-btn" style="margin-bottom:0">← Zurück</button>
<div style="display:flex; align-items:center; gap:0.6rem;">
<span style="font-size:0.82rem; color:var(--muted);">Dating-Modus</span>
<label class="switch">
<input type="checkbox" id="toggle-tinder-card" checked>
<span class="knob"></span>
</label>
</div>
<button class="tinder-done-btn" id="tinder-skip-rest">Restliche überspringen</button>
</div>
<div style="text-align:center; margin-bottom:0.5rem;">
<span class="tinder-counter" id="tinder-counter">1 / 0</span>
</div>
<div class="tinder-arena">
<div class="tinder-card" id="tinder-card">
<img id="tinder-img" src="" alt="">
<div class="tinder-overlay tinder-overlay-ok" id="tinder-overlay-ok">i.O.</div>
<div class="tinder-overlay tinder-overlay-nok" id="tinder-overlay-nok">n.i.O.</div>
<div class="tinder-overlay tinder-overlay-fav" id="tinder-overlay-fav"></div>
<button class="tinder-fav-star" id="tinder-fav-star" title="Favorit (F)"></button>
<div class="tinder-info">
<span class="tinder-name" id="tinder-name"></span>
<span id="tinder-badge"></span>
</div>
</div>
</div>
<div class="tinder-hints">
<span class="tinder-hint-nok">← Aussortieren</span>
<span style="font-size:0.72rem; color:var(--faint);">⌫ Undo</span>
<span class="tinder-hint-ok">Behalten →</span>
</div>
<div class="tinder-btn-row" style="justify-content:center;">
<button class="tinder-btn tinder-btn-nok" id="tinder-btn-nok"></button>
<button class="tinder-btn tinder-btn-ok" id="tinder-btn-ok"></button>
</div>
</div>
</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>
// --- Auth ---
let _authToken = sessionStorage.getItem("of_token") || "";
function apiFetch(url, opts = {}) {
opts.headers = { ...(opts.headers || {}) };
if (_authToken) opts.headers["Authorization"] = "Bearer " + _authToken;
return fetch(url, opts).then(r => {
if (r.status === 401) { _authToken = ""; sessionStorage.removeItem("of_token"); showView("view-login"); }
return r;
});
}
async function initAuth() {
if (!_authToken) {
// Try no-auth (APP_PASSWORD not set)
const r = await fetch("login", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({password:""}) });
if (r.ok) { const d = await r.json(); _authToken = d.token; sessionStorage.setItem("of_token", _authToken); showView("view-start"); }
else showView("view-login");
} else {
showView("view-start");
}
}
// --- State ---
let analysisResults = [];
let okPaths = [];
let currentFolder = "";
let uploadedFolder = "";
// --- 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); }
const SLOGANS = [
"nur die heißen Shots bleiben",
"weniger Schrott, mehr Shot",
"kuratiert deine Bilder mit gutem Geschmack",
"das Beste aus deinem Kameraroll",
"weil nicht jedes Foto Main Character Energy hat",
"wir lassen nur die guten rein",
"aus 500 Bildern werden 20, die zählen",
"selektiv, attraktiv, fotografisch",
"weniger Durstfalle, mehr Volltreffer",
"die einzig seriöse Art, Only zu abonnieren",
"hier werden nur Bilder entblößt",
"nicht anrüchig, nur anspruchsvoll",
"premium picks, ohne peinliche Ausreißer",
"für Bilder, die es wirklich wert sind, gesehen zu werden",
"keep the bangers",
"cut the clutter",
"frame only the best",
"curate your chaos",
"your gallery, edited",
"less scroll, more wow",
"intelligente Fotokuration mit Charakter",
"dein Geschmack, nur schneller",
"aus Masse wird Auswahl",
"kuratiere, was wirklich bleibt",
"die App für den zweiten, besseren Blick",
];
const sloganEl = el("app-slogan");
sloganEl.textContent = SLOGANS[Math.floor(Math.random() * SLOGANS.length)];
sloganEl.classList.add("pop");
sloganEl.addEventListener("animationend", () => sloganEl.classList.remove("pop"), { once: true });
// --- 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; });
});
// --- Presets ---
const PRESETS = {
standard: { blur: 100, over: 240, under: 30, dup: 8 },
outdoor: { blur: 80, over: 235, under: 15, dup: 8 },
night: { blur: 40, over: 210, under: 5, dup: 10 },
portrait: { blur: 150, over: 245, under: 30, dup: 5 },
nature: { blur: 60, over: 238, under: 12, dup: 10 },
studio: { blur: 160, over: 248, under: 40, dup: 4 },
};
el("preset-select").addEventListener("change", () => {
const preset = PRESETS[el("preset-select").value];
if (!preset) return;
["blur", "over", "under", "dup"].forEach(key => {
const input = el(key + "-threshold");
input.value = preset[key];
el(key + "-val").textContent = preset[key];
});
});
// --- Folder picker ---
// --- Drag & Drop Upload ---
const BATCH = 15;
const IMG_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".heic", ".heif"]);
async function readDroppedFolder(dataTransfer) {
const files = [];
async function readEntry(entry) {
if (entry.isFile) {
const file = await new Promise(resolve => entry.file(resolve));
const ext = file.name.slice(file.name.lastIndexOf(".")).toLowerCase();
if (IMG_EXTS.has(ext)) files.push(file);
} else if (entry.isDirectory) {
const reader = entry.createReader();
let batch;
do {
batch = await new Promise(resolve => reader.readEntries(resolve));
for (const e of batch) await readEntry(e);
} while (batch.length > 0);
}
}
for (const item of [...dataTransfer.items]) {
const entry = item.webkitGetAsEntry?.();
if (entry) await readEntry(entry);
}
return files;
}
async function uploadFiles(files) {
el("upload-status").style.display = "";
el("drop-zone").classList.remove("done");
const bar = el("upload-bar");
const lbl = el("upload-label");
let folder = "";
let done = 0;
for (let i = 0; i < files.length; i += BATCH) {
const fd = new FormData();
if (folder) fd.append("folder", folder);
files.slice(i, i + BATCH).forEach(f => fd.append("files", f, f.name));
const res = await apiFetch("upload", { method: "POST", body: fd });
if (!res.ok) throw new Error("Upload fehlgeschlagen");
const data = await res.json();
folder = data.folder;
done = Math.min(i + BATCH, files.length);
const pct = Math.round(done / files.length * 100);
bar.style.width = pct + "%";
lbl.textContent = done + " / " + files.length + " Fotos hochgeladen";
}
return folder;
}
const dropZone = el("drop-zone");
dropZone.addEventListener("dragover", e => {
e.preventDefault();
dropZone.classList.add("drag-over");
});
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
dropZone.addEventListener("drop", async e => {
e.preventDefault();
dropZone.classList.remove("drag-over");
el("drop-sub").textContent = "Lese Dateien...";
let files;
try {
files = await readDroppedFolder(e.dataTransfer);
} catch (err) {
el("drop-sub").textContent = "Fehler beim Lesen: " + err.message;
return;
}
if (files.length === 0) {
el("drop-sub").textContent = "Keine Fotos gefunden (JPG, PNG, HEIC, WebP).";
return;
}
el("drop-sub").textContent = files.length + " Fotos gefunden, lade hoch...";
try {
const folder = await uploadFiles(files);
uploadedFolder = folder;
el("folder-input").value = folder;
dropZone.classList.add("done");
el("drop-sub").textContent = files.length + " Fotos bereit ✓";
el("upload-status").style.display = "none";
loadUploads();
} catch (err) {
el("drop-sub").textContent = "Upload-Fehler: " + err.message;
}
});
// Ordner-Auswahl per Dialog
el("folder-picker-btn").addEventListener("click", e => {
e.stopPropagation();
el("folder-picker").click();
});
el("folder-picker").addEventListener("change", async () => {
const all = [...el("folder-picker").files];
const files = all.filter(f => IMG_EXTS.has(f.name.slice(f.name.lastIndexOf(".")).toLowerCase()));
if (files.length === 0) {
el("drop-sub").textContent = "Keine Fotos im gewählten Ordner (JPG, PNG, HEIC, WebP).";
return;
}
el("drop-sub").textContent = files.length + " Fotos gefunden, lade hoch…";
try {
const folder = await uploadFiles(files);
uploadedFolder = folder;
el("folder-input").value = folder;
dropZone.classList.add("done");
el("drop-sub").textContent = files.length + " Fotos bereit ✓";
el("upload-status").style.display = "none";
loadUploads();
} catch (err) {
el("drop-sub").textContent = "Upload-Fehler: " + err.message;
}
el("folder-picker").value = "";
});
// --- Upload-Übersicht ---
function fmtSize(bytes) {
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
return (bytes / (1024 * 1024)).toFixed(1) + " MB";
}
async function loadUploads() {
const list = el("uploads-list");
try {
const data = await apiFetch("uploads").then(r => r.json());
list.innerHTML = "";
if (data.sessions.length === 0) {
list.innerHTML = '<p class="uploads-empty">Keine Uploads auf dem Server</p>';
return;
}
data.sessions.forEach((s) => {
const folderName = s.folder.split("/").pop();
const isActive = s.folder === uploadedFolder;
const row = document.createElement("div");
row.className = "upload-row" + (isActive ? " upload-row-active" : "");
row.innerHTML = `
<span class="upload-row-icon">📁</span>
<div class="upload-row-info">
<div class="upload-row-name">${folderName}</div>
<div class="upload-row-meta">${s.count} Fotos &middot; ${fmtSize(s.size)}</div>
</div>
<button class="upload-row-del" data-folder="${s.folder}" title="Löschen">🗑</button>
`;
row.addEventListener("click", (e) => {
if (e.target.closest(".upload-row-del")) return;
uploadedFolder = s.folder;
el("folder-input").value = s.folder;
dropZone.classList.add("done");
el("drop-sub").textContent = s.count + " Fotos bereit ✓";
el("drop-sub").style.color = "";
loadUploads();
});
row.querySelector(".upload-row-del").addEventListener("click", async () => {
await apiFetch("uploads?folder=" + encodeURIComponent(s.folder), { method: "DELETE" });
if (uploadedFolder === s.folder) {
uploadedFolder = "";
el("folder-input").value = "";
dropZone.classList.remove("done");
el("drop-sub").textContent = "";
}
loadUploads();
});
list.appendChild(row);
});
} catch (e) {
list.innerHTML = '<p class="uploads-empty">Fehler beim Laden</p>';
}
}
// --- Login ---
el("login-btn").addEventListener("click", async () => {
const pw = el("login-pw").value;
el("login-error").textContent = "";
try {
const r = await fetch("login", { method: "POST", headers: {"Content-Type":"application/json"}, body: JSON.stringify({password: pw}) });
if (!r.ok) { el("login-error").textContent = "Falsches Passwort"; return; }
const d = await r.json();
_authToken = d.token;
sessionStorage.setItem("of_token", _authToken);
el("login-pw").value = "";
showView("view-start");
loadUploads();
} catch { el("login-error").textContent = "Verbindungsfehler"; }
});
el("login-pw").addEventListener("keydown", e => { if (e.key === "Enter") el("login-btn").click(); });
initAuth().then(() => { if (_authToken) loadUploads(); });
el("uploads-refresh").addEventListener("click", loadUploads);
// --- Folder Browser ---
let fbCurrentPath = "";
async function fbOpen() {
const start = el("folder-input").value.trim() || "/home/vchuser";
await fbNavigate(start);
el("fb-backdrop").classList.add("open");
}
async function fbNavigate(path) {
el("fb-list").innerHTML = '<div class="fb-empty">Lade...</div>';
try {
const res = await apiFetch("browse?path=" + encodeURIComponent(path));
if (!res.ok) { const e = await res.json(); throw new Error(e.detail); }
const data = await res.json();
fbCurrentPath = data.path;
el("fb-path").textContent = data.path;
el("fb-list").innerHTML = "";
if (data.parent !== null) {
const up = document.createElement("div");
up.className = "fb-item fb-item-up";
up.innerHTML = "&#8593; &nbsp;..";
up.addEventListener("click", () => fbNavigate(data.parent));
el("fb-list").appendChild(up);
}
if (data.dirs.length === 0) {
const empty = document.createElement("div");
empty.className = "fb-empty";
empty.textContent = "Keine Unterordner";
el("fb-list").appendChild(empty);
} else {
data.dirs.forEach(name => {
const item = document.createElement("div");
item.className = "fb-item";
item.innerHTML = "&#128193; &nbsp;" + name;
item.addEventListener("click", () => fbNavigate(data.path + "/" + name));
el("fb-list").appendChild(item);
});
}
} catch (e) {
el("fb-list").innerHTML = '<div class="fb-empty">Fehler: ' + e.message + '</div>';
}
}
// pick-btn entfernt (nur noch Drag & Drop und Ordner-Auswahl-Dialog)
el("fb-select").addEventListener("click", () => {
el("folder-input").value = fbCurrentPath;
el("fb-backdrop").classList.remove("open");
});
el("fb-cancel").addEventListener("click", () => el("fb-backdrop").classList.remove("open"));
el("fb-backdrop").addEventListener("click", e => {
if (e.target === el("fb-backdrop")) el("fb-backdrop").classList.remove("open");
});
// --- Navigation ---
function goToStart() { showView("view-start"); }
el("cancel-btn").addEventListener("click", goToStart);
el("review-back-btn").addEventListener("click", goToStart);
el("tinder-back-btn").addEventListener("click", goToStart);
el("result-back-btn").addEventListener("click", goToStart);
el("restart-btn").addEventListener("click", () => location.reload());
// --- Start Analysis ---
el("start-btn").addEventListener("click", async () => {
try {
const folder = uploadedFolder || el("folder-input").value.trim();
if (!folder) {
el("drop-sub").textContent = "Bitte zuerst einen Ordner hochladen.";
el("drop-sub").style.color = "var(--red)";
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 {
// Start job
const startRes = await apiFetch("analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!startRes.ok) {
const e = await startRes.json().catch(() => ({}));
throw new Error(e.detail || "Serverfehler " + startRes.status);
}
const { job_id } = await startRes.json();
// Nonsense-Wörter à la Claude Code
const progressWords = [
"Grübelt…", "Kontempliert…", "Philosophiert…", "Meditiert…",
"Sinniert…", "Reflektiert…", "Kalkuliert…", "Spekuliert…",
"Rumgrübelt…", "Ponderiert…", "Deliberiert…", "Ergründet…",
"Imaginiert…", "Prozessiert…", "Evaluiert…", "Scrutiniert…",
"Analysiert…", "Observiert…", "Deduziert…", "Katalogisiert…",
];
let wordIdx = Math.floor(Math.random() * progressWords.length);
function cycleWord() {
const el_word = el("progress-word");
el_word.classList.add("slide-out-left");
el_word.addEventListener("animationend", () => {
wordIdx = (wordIdx + 1) % progressWords.length;
el_word.textContent = progressWords[wordIdx];
el_word.classList.remove("slide-out-left");
el_word.classList.add("slide-in-right");
el_word.addEventListener("animationend", () => {
el_word.classList.remove("slide-in-right");
}, { once: true });
}, { once: true });
}
el("progress-word").textContent = progressWords[wordIdx];
const wordTimer = setInterval(cycleWord, 3000);
// Poll until done
const bar = el("progress-bar");
bar.removeAttribute("value");
try {
while (true) {
await new Promise(r => setTimeout(r, 800));
const poll = await apiFetch("analyze/status/" + job_id);
if (!poll.ok) {
const e = await poll.json().catch(() => ({}));
throw new Error(e.detail || "Analysefehler " + poll.status);
}
const pollData = await poll.json();
if (pollData.status === "running") {
const { done, total } = pollData;
if (total > 0) {
bar.max = total;
bar.value = done;
el("progress-label").textContent = done + " / " + total + " Fotos";
}
continue;
}
bar.max = 1; bar.value = 1;
data = pollData;
break;
}
} finally {
clearInterval(wordTimer);
}
} catch (e) {
alert("Fehler bei der Analyse: " + e.message);
showView("view-start");
return;
}
analysisResults = data.results;
okPaths = data.ok_paths || [];
if (analysisResults.length === 0) {
renderResult(0);
return;
}
if (el("toggle-tinder").checked) {
startTinder();
} else if (el("toggle-review").checked) {
renderReview();
showView("view-review");
} else {
await doMove();
}
} catch(err) {
alert("JS-Fehler: " + err.message);
showView("view-start");
}
});
// --- 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 = "";
// Alle Fotos zusammenführen und nach Dateiname sortieren
const flaggedMap = {};
analysisResults.forEach((item, idx) => { flaggedMap[item.path] = { item, idx }; });
const allEntries = [
...analysisResults.map((item, idx) => ({ path: item.path, flagged: true, reasons: item.reasons, idx })),
...okPaths.map(path => ({ path, flagged: false, reasons: [], idx: null })),
].sort((a, b) => a.path.split("/").pop().localeCompare(b.path.split("/").pop()));
allEntries.forEach(entry => {
const name = entry.path.split("/").pop();
const row = document.createElement("div");
row.className = "photo-item";
if (entry.idx !== null) row.id = "item-" + entry.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 = entry.flagged ? "badge-reject" : "badge-ok";
badge.textContent = entry.flagged ? "n.i.O." : "i.O.";
nameEl.appendChild(badge);
info.appendChild(nameEl);
if (entry.reasons.length > 0) {
const reasonsEl = document.createElement("div");
reasonsEl.className = "photo-reasons";
reasonsEl.textContent = entry.reasons.join(", ");
info.appendChild(reasonsEl);
}
const btn = document.createElement("button");
btn.className = "keep-btn";
btn.textContent = entry.flagged ? "Behalten" : "Aussortieren";
btn.addEventListener("click", () => {
entry.flagged = !entry.flagged;
badge.className = entry.flagged ? "badge-reject" : "badge-ok";
badge.textContent = entry.flagged ? "n.i.O." : "i.O.";
btn.textContent = entry.flagged ? "Behalten" : "Aussortieren";
// Sync mit analysisResults für doMove
if (entry.idx !== null) {
row.classList.toggle("kept", !entry.flagged);
} else {
// Manuell zum Aussortieren markiert
row.id = "manual-" + entry.path;
}
});
row.appendChild(makeThumb(entry.path, name));
row.appendChild(info);
row.appendChild(btn);
list.appendChild(row);
});
// Für doMove: manuelle Auswahl über allEntries tracken
list._allEntries = allEntries;
}
// --- Confirm & Move ---
el("confirm-btn").addEventListener("click", () => doMove(false));
async function doMove(skipReview = true) {
let toMove;
if (skipReview) {
toMove = analysisResults.map(r => r.path);
} else {
const entries = el("review-list")._allEntries || [];
toMove = entries.filter(e => e.flagged).map(e => e.path);
}
if (toMove.length === 0) {
renderResult(0);
return;
}
showView("view-progress");
el("progress-label").textContent = "Verschiebe " + toMove.length + " Fotos...";
try {
const res = await apiFetch("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");
}
}
// --- Tinder Mode ---
let tinderQueue = [];
let tinderIndex = 0;
let tinderDecisions = {}; // path -> true (keep) | false (reject) | "fav"
let tinderSwiping = false;
let tinderHistory = []; // [{path, decision, index}]
let favoritePaths = new Set();
function startTinder() {
tinderQueue = [
...analysisResults.map(r => ({ path: r.path, name: r.path.split("/").pop(), suggested: false })),
...okPaths.map(p => ({ path: p, name: p.split("/").pop(), suggested: true })),
].sort((a, b) => a.name.localeCompare(b.name));
tinderIndex = 0;
tinderDecisions = {};
tinderHistory = [];
favoritePaths = new Set();
tinderSwiping = false;
showView("view-tinder");
loadTinderCard(true);
}
function loadTinderCard(initial = false, backAnim = null) {
if (tinderIndex >= tinderQueue.length) { finishTinder(); return; }
const photo = tinderQueue[tinderIndex];
const card = el("tinder-card");
card.classList.remove("fly-left","fly-right","fly-up","drop-in","back-from-left","back-from-right","back-from-top");
void card.offsetWidth;
el("tinder-img").src = "preview?path=" + encodeURIComponent(photo.path);
el("tinder-name").textContent = photo.name;
el("tinder-counter").textContent = (tinderIndex + 1) + " / " + tinderQueue.length;
el("tinder-overlay-ok").style.opacity = "0";
el("tinder-overlay-nok").style.opacity = "0";
el("tinder-overlay-fav").style.opacity = "0";
const badge = el("tinder-badge");
badge.className = photo.suggested ? "badge-ok" : "badge-reject";
badge.textContent = photo.suggested ? "i.O." : "n.i.O.";
const star = el("tinder-fav-star");
star.classList.toggle("active", favoritePaths.has(photo.path));
if (backAnim) {
card.classList.add(backAnim);
card.addEventListener("animationend", () => card.classList.remove(backAnim), { once: true });
} else if (!initial) {
card.classList.add("drop-in");
card.addEventListener("animationend", () => card.classList.remove("drop-in"), { once: true });
}
}
function tinderSwipe(direction) {
if (tinderSwiping || tinderIndex >= tinderQueue.length) return;
tinderSwiping = true;
const photo = tinderQueue[tinderIndex];
const card = el("tinder-card");
tinderHistory.push({ path: photo.path, decision: direction, index: tinderIndex, wasFav: favoritePaths.has(photo.path) });
tinderDecisions[photo.path] = direction === "right" || direction === "fav";
if (direction === "fav") favoritePaths.add(photo.path);
const overlayId = direction === "right" ? "tinder-overlay-ok"
: direction === "fav" ? "tinder-overlay-fav"
: "tinder-overlay-nok";
el(overlayId).style.opacity = "1";
const animClass = direction === "left" ? "fly-left" : direction === "fav" ? "fly-up" : "fly-right";
card.classList.add(animClass);
setTimeout(() => {
tinderIndex++;
tinderSwiping = false;
loadTinderCard();
}, 320);
}
function tinderUndo() {
if (tinderSwiping || tinderHistory.length === 0) return;
const last = tinderHistory.pop();
delete tinderDecisions[last.path];
if (last.wasFav) favoritePaths.add(last.path); else favoritePaths.delete(last.path);
tinderIndex = last.index;
const backAnim = last.decision === "left" ? "back-from-left"
: last.decision === "fav" ? "back-from-top"
: "back-from-right";
loadTinderCard(false, backAnim);
}
function tinderFavorite() {
if (tinderSwiping || tinderIndex >= tinderQueue.length) return;
const photo = tinderQueue[tinderIndex];
const star = el("tinder-fav-star");
favoritePaths.add(photo.path);
star.classList.add("active");
star.classList.remove("bounce");
void star.offsetWidth;
star.classList.add("bounce");
star.addEventListener("animationend", () => star.classList.remove("bounce"), { once: true });
setTimeout(() => tinderSwipe("fav"), 230);
}
function finishTinder() {
const toMove = Object.entries(tinderDecisions)
.filter(([, keep]) => !keep)
.map(([path]) => path);
if (toMove.length === 0) { renderResult(0); return; }
showView("view-progress");
el("progress-label").textContent = "Verschiebe " + toMove.length + " Fotos...";
apiFetch("move", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths: toMove, folder: currentFolder }),
})
.then(r => r.json())
.then(data => renderResult(data.moved.length))
.catch(e => { alert("Fehler: " + e.message); });
}
// Keyboard
document.addEventListener("keydown", e => {
if (!el("view-tinder").classList.contains("active")) return;
if (e.key === "ArrowLeft") { e.preventDefault(); tinderSwipe("left"); }
if (e.key === "ArrowRight") { e.preventDefault(); tinderSwipe("right"); }
if (e.key === "ArrowUp" || e.key === "f" || e.key === "F") { e.preventDefault(); tinderFavorite(); }
if (e.key === "Backspace") { e.preventDefault(); tinderUndo(); }
});
el("tinder-btn-nok").addEventListener("click", () => tinderSwipe("left"));
el("tinder-btn-ok").addEventListener("click", () => tinderSwipe("right"));
// Touch swipe
const SWIPE_H = 80; // px horizontal threshold
const SWIPE_UP = 75; // px upward threshold (favorite)
let _tx0 = 0, _ty0 = 0, _touching = false;
function _resetOverlays() {
el("tinder-overlay-ok").style.opacity = "0";
el("tinder-overlay-nok").style.opacity = "0";
el("tinder-overlay-fav").style.opacity = "0";
}
el("tinder-card").addEventListener("touchstart", e => {
if (tinderSwiping) return;
const t = e.touches[0];
_tx0 = t.clientX; _ty0 = t.clientY;
_touching = true;
el("tinder-card").classList.remove("snapping");
}, { passive: true });
el("tinder-card").addEventListener("touchmove", e => {
if (!_touching || tinderSwiping) return;
e.preventDefault();
const t = e.touches[0];
const dx = t.clientX - _tx0;
const dy = t.clientY - _ty0;
const isUp = dy < -SWIPE_UP * 0.5 && Math.abs(dx) < 55;
const rot = isUp ? 0 : (dx / el("tinder-card").offsetWidth) * 18;
el("tinder-card").style.transform =
`translateX(${isUp ? 0 : dx}px) translateY(${isUp ? dy * 0.6 : 0}px) rotate(${rot}deg)`;
el("tinder-overlay-ok").style.opacity = (!isUp && dx > 30) ? Math.min(1, (dx - 30) / 70) + "" : "0";
el("tinder-overlay-nok").style.opacity = (!isUp && dx < -30) ? Math.min(1, (-dx - 30) / 70) + "" : "0";
el("tinder-overlay-fav").style.opacity = isUp ? Math.min(1, (-dy - 37) / 50) + "" : "0";
}, { passive: false });
function _onTouchEnd(e) {
if (!_touching) return;
_touching = false;
const t = e.changedTouches[0];
const dx = t.clientX - _tx0;
const dy = t.clientY - _ty0;
el("tinder-card").style.transform = "";
_resetOverlays();
if (dy < -SWIPE_UP && Math.abs(dx) < 55) {
tinderFavorite();
} else if (dx > SWIPE_H) {
tinderSwipe("right");
} else if (dx < -SWIPE_H) {
tinderSwipe("left");
} else {
const card = el("tinder-card");
card.classList.add("snapping");
card.addEventListener("transitionend", () => card.classList.remove("snapping"), { once: true });
}
}
el("tinder-card").addEventListener("touchend", _onTouchEnd);
el("tinder-card").addEventListener("touchcancel", _onTouchEnd);
el("tinder-fav-star").addEventListener("click", () => {
const photo = tinderQueue[tinderIndex];
if (!photo) return;
if (favoritePaths.has(photo.path)) {
// Toggle off
favoritePaths.delete(photo.path);
el("tinder-fav-star").classList.remove("active");
} else {
favoritePaths.add(photo.path);
el("tinder-fav-star").classList.add("active");
}
});
el("tinder-skip-rest").addEventListener("click", () => {
for (let i = tinderIndex; i < tinderQueue.length; i++) {
tinderDecisions[tinderQueue[i].path] = tinderQueue[i].suggested;
}
tinderIndex = tinderQueue.length;
finishTinder();
});
// Toggle Dating-Modus aus der Review-Liste heraus
el("toggle-tinder-inline").addEventListener("change", e => {
if (e.target.checked) {
el("toggle-tinder-card").checked = true;
startTinder();
}
});
// Toggle Dating-Modus aus der Tinder-View heraus (zurück zur Liste)
el("toggle-tinder-card").addEventListener("change", e => {
if (!e.target.checked) {
el("toggle-tinder-inline").checked = false;
renderReview();
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);
const dlBtn = el("download-btn");
if (currentFolder) {
dlBtn.href = "download?folder=" + encodeURIComponent(currentFolder);
dlBtn.style.display = "";
}
showView("view-result");
}
// --- Export tabs ---
document.querySelectorAll(".export-tab").forEach(tab => {
tab.addEventListener("click", () => {
document.querySelectorAll(".export-tab").forEach(t => t.classList.remove("active"));
document.querySelectorAll(".export-panel").forEach(p => p.classList.remove("active"));
tab.classList.add("active");
el("tab-" + tab.dataset.tab).classList.add("active");
});
});
el("rename-mode").addEventListener("change", () => {
el("rename-prefix-row").style.display =
el("rename-mode").value === "prefix_seq" ? "" : "none";
});
// Slider labels
[["exp-rotation","°"],["exp-brightness",""],["exp-contrast",""],["exp-saturation",""],
["wm-font-size",""],["wm-text-opacity",""],["wm-image-opacity",""],["wm-image-scale",""]
].forEach(([id, suffix]) => {
const inp = el(id), lbl = el(id + "-val");
if (inp && lbl) inp.addEventListener("input", () => lbl.textContent = inp.value + suffix);
});
// Watermark sub-tabs
el("wm-sub-text").addEventListener("click", () => {
el("wm-text-panel").style.display = "";
el("wm-image-panel").style.display = "none";
document.querySelectorAll(".wm-sub").forEach(b => b.classList.remove("active"));
el("wm-sub-text").classList.add("active");
});
el("wm-sub-image").addEventListener("click", () => {
el("wm-text-panel").style.display = "none";
el("wm-image-panel").style.display = "";
document.querySelectorAll(".wm-sub").forEach(b => b.classList.remove("active"));
el("wm-sub-image").classList.add("active");
});
// Watermark image upload
let wmImagePath = "";
el("wm-image-upload").addEventListener("change", async () => {
const file = el("wm-image-upload").files[0];
if (!file) return;
el("wm-image-status").textContent = "Lädt hoch…";
const fd = new FormData();
fd.append("file", file, file.name);
fd.append("folder", currentFolder);
try {
const res = await apiFetch("upload-watermark", { method: "POST", body: fd });
const data = await res.json();
wmImagePath = data.path;
el("wm-image-status").textContent = "✓ " + file.name;
} catch { el("wm-image-status").textContent = "Upload fehlgeschlagen"; }
});
// Auto-detect horizon
el("auto-detect-angle").addEventListener("click", async () => {
const sample = okPaths[0] || (analysisResults[0] && analysisResults[0].path);
if (!sample) return;
el("auto-detect-angle").textContent = "Erkenne…";
try {
const res = await apiFetch("detect-angle?path=" + encodeURIComponent(sample));
const data = await res.json();
const angle = (-data.angle).toFixed(1);
el("exp-rotation").value = angle;
el("exp-rotation-val").textContent = angle + "°";
} catch {}
el("auto-detect-angle").textContent = "↗ Horizont auto-erkennen";
});
// Export job
el("export-btn").addEventListener("click", async () => {
el("export-progress").style.display = "";
el("export-btn").disabled = true;
const bar = el("export-bar");
bar.removeAttribute("value");
el("export-label").textContent = "Export startet…";
const wmTextVal = el("wm-text").value.trim();
const activeWmSub = el("wm-sub-text").classList.contains("active") ? "text" : "image";
const payload = {
folder: currentFolder,
paths: okPaths,
fav_paths: [...favoritePaths],
rename_mode: el("rename-mode").value,
rename_prefix: el("rename-prefix").value.trim(),
fav_prefix: el("fav-prefix").value || "FAV_",
rotation: parseFloat(el("exp-rotation").value),
brightness: parseFloat(el("exp-brightness").value),
contrast: parseFloat(el("exp-contrast").value),
saturation: parseFloat(el("exp-saturation").value),
text_watermark: (activeWmSub === "text" && wmTextVal) ? {
text: wmTextVal,
position: el("wm-text-pos").value,
font_size: parseInt(el("wm-font-size").value),
opacity: parseFloat(el("wm-text-opacity").value),
} : {},
image_watermark_path: (activeWmSub === "image" && wmImagePath) ? wmImagePath : "",
image_watermark_settings: (activeWmSub === "image" && wmImagePath) ? {
position: el("wm-image-pos").value,
opacity: parseFloat(el("wm-image-opacity").value),
scale: parseInt(el("wm-image-scale").value) / 100,
} : {},
feature_detectors: [
...(el("feat-qr").checked ? ["qr"] : []),
...(el("feat-bc").checked ? ["barcode"] : []),
...(el("feat-face").checked ? ["face"] : []),
...(el("feat-pano").checked ? ["pano"] : []),
],
};
try {
const startRes = await apiFetch("export", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!startRes.ok) throw new Error("Export konnte nicht gestartet werden");
const { job_id } = await startRes.json();
while (true) {
await new Promise(r => setTimeout(r, 800));
const poll = await apiFetch("export/status/" + job_id);
const data = await poll.json();
if (data.status === "running") {
if (data.total > 0) {
bar.max = data.total; bar.value = data.done;
el("export-label").textContent = data.done + " / " + data.total + " Fotos verarbeitet";
}
continue;
}
// done
window.location.href = "export/download/" + data.zip_id;
break;
}
} catch(e) {
alert("Export-Fehler: " + e.message);
}
el("export-btn").disabled = false;
el("export-progress").style.display = "none";
});
</script>
</body>
</html>