a08777d759
- #10: filename dedup counter zero-padded (_001, _002 instead of _1, _2) - #8: hint below auto-detect button clarifies it samples first photo only - #9: note above export button that all output is JPEG (PNG alpha lost) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1562 lines
73 KiB
HTML
1562 lines
73 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>
|
|
<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 { font-size: 2.2rem; margin-bottom: 0.5rem; }
|
|
.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; }
|
|
.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; }
|
|
|
|
/* ── 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">📁</div>
|
|
<p class="drop-text">Drag & 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 · 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 >)</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 <)</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 ≤)</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 & 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>
|
|
|
|
<!-- 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">0°</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 & 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">×</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 · ${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 = "↑ ..";
|
|
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 = "📁 " + 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 });
|
|
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];
|
|
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"));
|
|
|
|
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,
|
|
} : {},
|
|
};
|
|
|
|
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>
|