lodestones commited on
Commit
ce0b052
·
verified ·
1 Parent(s): 35740a6

Update tagger_ui/templates/index.html

Browse files
Files changed (1) hide show
  1. tagger_ui/templates/index.html +390 -311
tagger_ui/templates/index.html CHANGED
@@ -8,268 +8,200 @@
8
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
 
10
  :root {
11
- --bg: #0f0f11;
12
- --surface: #1a1a1f;
13
- --border: #2e2e38;
14
- --accent: #7c6af7;
15
- --accent2: #a78bfa;
16
- --text: #e2e2e8;
17
- --muted: #6b6b7e;
18
- --green: #4ade80;
19
- --radius: 10px;
20
  }
21
 
22
  body {
23
- background: var(--bg);
24
- color: var(--text);
25
  font-family: 'Inter', system-ui, sans-serif;
26
- min-height: 100vh;
27
- display: flex;
28
- flex-direction: column;
29
- align-items: center;
30
- padding: 2rem 1rem 4rem;
31
  }
32
 
33
- h1 {
34
- font-size: 1.6rem;
35
- font-weight: 700;
36
- letter-spacing: -0.02em;
37
- margin-bottom: 0.25rem;
38
- }
39
- h1 span { color: var(--accent2); }
40
-
41
- .subtitle {
42
- color: var(--muted);
43
- font-size: 0.85rem;
44
- margin-bottom: 2rem;
45
- }
46
 
47
  .card {
48
- background: var(--surface);
49
- border: 1px solid var(--border);
50
- border-radius: var(--radius);
51
- padding: 1.5rem;
52
- width: 100%;
53
- max-width: 780px;
54
- }
55
-
56
- /* ---- input area ---- */
57
- .input-row {
58
- display: flex;
59
- gap: 0.5rem;
60
- margin-bottom: 1rem;
61
  }
62
 
 
 
63
  .input-row input[type="text"] {
64
- flex: 1;
65
- background: var(--bg);
66
- border: 1px solid var(--border);
67
- border-radius: var(--radius);
68
- color: var(--text);
69
- font-size: 0.9rem;
70
- padding: 0.6rem 0.9rem;
71
- outline: none;
72
- transition: border-color 0.15s;
73
  }
74
  .input-row input[type="text"]:focus { border-color: var(--accent); }
75
  .input-row input[type="text"]::placeholder { color: var(--muted); }
76
 
77
  .btn {
78
- background: var(--accent);
79
- border: none;
80
- border-radius: var(--radius);
81
- color: #fff;
82
- cursor: pointer;
83
- font-size: 0.9rem;
84
- font-weight: 600;
85
- padding: 0.6rem 1.2rem;
86
- transition: opacity 0.15s;
87
- white-space: nowrap;
88
  }
89
- .btn:hover { opacity: 0.85; }
90
- .btn:disabled { opacity: 0.4; cursor: not-allowed; }
91
 
92
  /* ---- drop zone ---- */
93
  #drop-zone {
94
- border: 2px dashed var(--border);
95
- border-radius: var(--radius);
96
- color: var(--muted);
97
- cursor: pointer;
98
- font-size: 0.85rem;
99
- padding: 1.4rem;
100
- text-align: center;
101
- transition: border-color 0.15s, background 0.15s;
102
- margin-bottom: 1rem;
103
- }
104
- #drop-zone.drag-over {
105
- border-color: var(--accent);
106
- background: rgba(124,106,247,0.06);
107
  }
 
108
  #drop-zone input[type="file"] { display: none; }
109
 
110
- /* ---- options row ---- */
111
  .options-row {
112
- display: flex;
113
- align-items: center;
114
- gap: 1rem;
115
- flex-wrap: wrap;
116
- margin-bottom: 1.2rem;
117
- font-size: 0.85rem;
118
- color: var(--muted);
119
  }
120
- .options-row label { display: flex; align-items: center; gap: 0.4rem; }
121
- .options-row input[type="number"],
122
- .options-row input[type="range"] {
123
- background: var(--bg);
124
- border: 1px solid var(--border);
125
- border-radius: 6px;
126
- color: var(--text);
127
- padding: 0.3rem 0.5rem;
128
- width: 70px;
129
- font-size: 0.85rem;
130
  }
131
  .options-row input[type="range"] {
132
- width: 110px;
133
- padding: 0;
134
- cursor: pointer;
135
- accent-color: var(--accent);
136
- }
137
- #threshold-val { color: var(--text); min-width: 2.5ch; }
138
-
139
- /* ---- mode toggle ---- */
140
- .mode-toggle {
141
- display: flex;
142
- background: var(--bg);
143
- border: 1px solid var(--border);
144
- border-radius: 8px;
145
- overflow: hidden;
146
- }
147
- .mode-toggle button {
148
- background: none;
149
- border: none;
150
- color: var(--muted);
151
- cursor: pointer;
152
- font-size: 0.8rem;
153
- padding: 0.3rem 0.7rem;
154
- transition: background 0.15s, color 0.15s;
155
  }
156
- .mode-toggle button.active {
157
- background: var(--accent);
158
- color: #fff;
159
- }
160
-
161
- /* ---- preview + results ---- */
162
- #results-area { display: none; }
163
 
164
- .result-block {
165
- display: grid;
166
- grid-template-columns: auto 1fr;
167
- gap: 1.2rem;
168
- margin-top: 1rem;
169
  }
 
 
 
 
 
170
 
 
171
  .preview-wrap {
172
- width: 200px;
173
- flex-shrink: 0;
174
  }
175
  .preview-wrap img {
176
- border-radius: var(--radius);
177
- max-width: 200px;
178
- max-height: 200px;
179
- object-fit: contain;
180
- border: 1px solid var(--border);
181
- display: block;
182
  }
183
- .preview-wrap .img-meta {
184
- color: var(--muted);
185
- font-size: 0.72rem;
186
- margin-top: 0.4rem;
187
- word-break: break-all;
188
- }
189
-
190
- .tags-wrap { min-width: 0; }
191
 
192
- .tag-copy-row {
193
- display: flex;
194
- align-items: center;
195
- gap: 0.5rem;
196
- margin-bottom: 0.7rem;
197
- }
198
  .tag-string {
199
- background: var(--bg);
200
- border: 1px solid var(--border);
201
- border-radius: 6px;
202
- color: var(--muted);
203
- font-size: 0.78rem;
204
- flex: 1;
205
- padding: 0.4rem 0.6rem;
206
- white-space: nowrap;
207
- overflow: hidden;
208
- text-overflow: ellipsis;
209
- cursor: pointer;
210
- transition: border-color 0.15s;
211
  }
212
  .tag-string:hover { border-color: var(--accent); color: var(--text); }
213
  .copy-btn {
214
- background: var(--bg);
215
- border: 1px solid var(--border);
216
- border-radius: 6px;
217
- color: var(--muted);
218
- cursor: pointer;
219
- font-size: 0.78rem;
220
- padding: 0.35rem 0.6rem;
221
- transition: border-color 0.15s, color 0.15s;
222
- white-space: nowrap;
223
  }
224
- .copy-btn:hover { border-color: var(--accent); color: var(--accent2); }
225
  .copy-btn.copied { color: var(--green); border-color: var(--green); }
226
 
227
- .tag-list {
228
- display: flex;
229
- flex-wrap: wrap;
230
- gap: 0.4rem;
231
- }
232
 
233
- .tag-pill {
234
- align-items: center;
235
- border-radius: 20px;
236
- display: inline-flex;
237
- font-size: 0.78rem;
238
- gap: 0.35rem;
239
- padding: 0.25rem 0.65rem;
240
- cursor: default;
241
- transition: opacity 0.1s;
242
  }
243
- .tag-pill:hover { opacity: 0.8; }
244
- .tag-pill .score {
245
- font-size: 0.68rem;
246
- opacity: 0.75;
 
 
 
 
 
 
247
  }
248
-
249
- /* ---- spinner ---- */
250
- .spinner {
251
- display: none;
252
- width: 22px; height: 22px;
253
- border: 3px solid var(--border);
254
- border-top-color: var(--accent);
255
- border-radius: 50%;
256
- animation: spin 0.7s linear infinite;
257
- margin: 1rem auto;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
258
  }
259
- @keyframes spin { to { transform: rotate(360deg); } }
260
 
261
- .error-msg {
262
- color: #f87171;
263
- font-size: 0.85rem;
264
- margin-top: 0.5rem;
265
- display: none;
 
 
 
 
 
 
 
 
 
 
 
 
266
  }
 
 
267
 
268
- @media (max-width: 520px) {
269
- .result-block { grid-template-columns: 1fr; }
270
- .preview-wrap { width: 100%; }
271
- .preview-wrap img { max-width: 100%; }
 
 
272
  }
 
 
 
273
  </style>
274
  </head>
275
  <body>
@@ -279,74 +211,91 @@
279
 
280
  <div class="card">
281
 
282
- <!-- URL input -->
283
  <div class="input-row">
284
  <input type="text" id="url-input" placeholder="Paste image URL or drop a file below…" />
285
  <button class="btn" id="tag-btn" onclick="runFromUrl()">Tag</button>
286
  </div>
287
 
288
- <!-- Drop zone -->
289
  <div id="drop-zone" onclick="document.getElementById('file-input').click()">
290
  <input type="file" id="file-input" accept="image/*" onchange="runFromFile(this)" />
291
  Drop image here or <strong>click to browse</strong>
292
  </div>
293
 
294
- <!-- Options -->
295
  <div class="options-row">
296
- <div class="mode-toggle">
297
- <button id="mode-topk" class="active" onclick="setMode('topk')">Top-K</button>
298
- <button id="mode-thresh" onclick="setMode('threshold')">Threshold</button>
299
- </div>
300
-
301
- <label id="topk-label">
302
- K =
303
- <input type="number" id="topk-input" value="40" min="1" max="500" />
304
- </label>
305
-
306
- <label id="thresh-label" style="display:none">
307
-
308
- <input type="range" id="thresh-input" min="0.01" max="0.99" step="0.01" value="0.35"
309
- oninput="document.getElementById('threshold-val').textContent=parseFloat(this.value).toFixed(2)" />
310
- <span id="threshold-val">0.35</span>
311
- </label>
312
-
313
  <label>
314
- Max px
315
- <input type="number" id="maxsize-input" value="1024" min="64" max="4096" step="16" />
 
 
 
 
 
316
  </label>
317
  </div>
318
 
319
  <div class="spinner" id="spinner"></div>
320
  <div class="error-msg" id="error-msg"></div>
321
 
322
- <!-- Results -->
323
  <div id="results-area">
324
- <div class="result-block">
325
- <div class="preview-wrap">
 
 
326
  <img id="preview-img" src="" alt="preview" />
327
  <div class="img-meta" id="img-meta"></div>
328
  </div>
329
- <div class="tags-wrap">
330
- <div class="tag-copy-row">
331
- <div class="tag-string" id="tag-string" onclick="copyTags()" title="Click to copy"></div>
332
- <button class="copy-btn" id="copy-btn" onclick="copyTags()">Copy</button>
333
- </div>
334
- <div class="tag-list" id="tag-list"></div>
335
- </div>
336
  </div>
 
 
 
 
 
 
 
 
 
 
337
  </div>
338
 
339
  </div>
340
 
341
  <script>
342
- let currentMode = 'topk';
343
-
344
- function setMode(m) {
345
- currentMode = m;
346
- document.getElementById('mode-topk').classList.toggle('active', m === 'topk');
347
- document.getElementById('mode-thresh').classList.toggle('active', m === 'threshold');
348
- document.getElementById('topk-label').style.display = m === 'topk' ? '' : 'none';
349
- document.getElementById('thresh-label').style.display = m === 'threshold' ? '' : 'none';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  }
351
 
352
  // ---- drag & drop ----
@@ -354,52 +303,29 @@
354
  dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag-over'); });
355
  dz.addEventListener('dragleave', () => dz.classList.remove('drag-over'));
356
  dz.addEventListener('drop', e => {
357
- e.preventDefault();
358
- dz.classList.remove('drag-over');
359
  const file = e.dataTransfer.files[0];
360
  if (file) submitFile(file);
361
  });
362
 
363
- function runFromFile(input) {
364
- if (input.files[0]) submitFile(input.files[0]);
365
- }
366
 
367
  function runFromUrl() {
368
  const url = document.getElementById('url-input').value.trim();
369
  if (!url) return;
370
- const params = buildParams();
371
- params.append('url', url);
372
- submit('/tag/url', params, url);
373
- }
374
-
375
- function buildParams() {
376
- const p = new URLSearchParams();
377
- p.append('max_size', document.getElementById('maxsize-input').value);
378
- if (currentMode === 'topk') {
379
- p.append('topk', document.getElementById('topk-input').value);
380
- } else {
381
- p.append('threshold', document.getElementById('thresh-input').value);
382
- }
383
- return p;
384
  }
385
 
386
  function submitFile(file) {
387
- const params = buildParams();
388
  const fd = new FormData();
389
  fd.append('file', file);
390
- for (const [k, v] of params) fd.append(k, v);
391
-
392
- // local preview
393
  const reader = new FileReader();
394
  reader.onload = e => setPreview(e.target.result, file.name);
395
  reader.readAsDataURL(file);
396
-
397
- submitFetch('/tag/upload', { method: 'POST', body: fd });
398
- }
399
-
400
- function submit(endpoint, params, previewUrl) {
401
- setPreview(previewUrl, previewUrl);
402
- submitFetch(`${endpoint}?${params}`, { method: 'POST' });
403
  }
404
 
405
  function submitFetch(url, opts) {
@@ -413,40 +339,197 @@
413
 
414
  function setPreview(src, label) {
415
  document.getElementById('preview-img').src = src;
416
- document.getElementById('img-meta').textContent = label.length > 60
417
- ? '…' + label.slice(-57) : label;
418
  }
419
 
 
 
 
 
 
420
  function renderResults(data) {
421
  hideError();
422
- const list = document.getElementById('tag-list');
423
- list.innerHTML = '';
424
-
425
- const tagString = data.tags.map(t => t.tag).join(', ');
426
- document.getElementById('tag-string').textContent = tagString;
427
-
428
- data.tags.forEach(({ tag, score }) => {
429
- const hue = Math.round(260 - score * 80); // purple → teal gradient
430
- const pill = document.createElement('span');
431
- pill.className = 'tag-pill';
432
- pill.style.background = `hsla(${hue},60%,55%,0.18)`;
433
- pill.style.border = `1px solid hsla(${hue},60%,55%,0.35)`;
434
- pill.style.color = `hsl(${hue},70%,78%)`;
435
- pill.innerHTML = `${tag}<span class="score">${(score * 100).toFixed(0)}%</span>`;
436
- pill.title = `${tag}: ${score.toFixed(4)}`;
437
- list.appendChild(pill);
438
- });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
439
 
440
  document.getElementById('results-area').style.display = 'block';
441
- document.getElementById('copy-btn').classList.remove('copied');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
442
  }
443
 
444
- function copyTags() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
445
  const text = document.getElementById('tag-string').textContent;
446
  navigator.clipboard.writeText(text).then(() => {
447
- const btn = document.getElementById('copy-btn');
448
- btn.textContent = 'Copied!';
449
- btn.classList.add('copied');
 
 
 
 
 
 
 
 
450
  setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1800);
451
  });
452
  }
@@ -459,14 +542,10 @@
459
 
460
  function showError(msg) {
461
  const el = document.getElementById('error-msg');
462
- el.textContent = msg;
463
- el.style.display = 'block';
464
- }
465
- function hideError() {
466
- document.getElementById('error-msg').style.display = 'none';
467
  }
 
468
 
469
- // allow pressing Enter in URL box
470
  document.getElementById('url-input').addEventListener('keydown', e => {
471
  if (e.key === 'Enter') runFromUrl();
472
  });
 
8
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
 
10
  :root {
11
+ --bg: #0f0f11;
12
+ --surface: #1a1a1f;
13
+ --border: #2e2e38;
14
+ --accent: #7c6af7;
15
+ --text: #e2e2e8;
16
+ --muted: #6b6b7e;
17
+ --green: #4ade80;
18
+ --radius: 10px;
 
19
  }
20
 
21
  body {
22
+ background: var(--bg); color: var(--text);
 
23
  font-family: 'Inter', system-ui, sans-serif;
24
+ min-height: 100vh; display: flex; flex-direction: column;
25
+ align-items: center; padding: 2rem 1rem 4rem;
 
 
 
26
  }
27
 
28
+ h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -.02em; margin-bottom: .25rem; }
29
+ h1 span { color: #a78bfa; }
30
+ .subtitle { color: var(--muted); font-size: .85rem; margin-bottom: 2rem; }
 
 
 
 
 
 
 
 
 
 
31
 
32
  .card {
33
+ background: var(--surface); border: 1px solid var(--border);
34
+ border-radius: var(--radius); padding: 1.5rem;
35
+ width: 100%; max-width: 900px;
 
 
 
 
 
 
 
 
 
 
36
  }
37
 
38
+ /* ---- input ---- */
39
+ .input-row { display: flex; gap: .5rem; margin-bottom: 1rem; }
40
  .input-row input[type="text"] {
41
+ flex: 1; background: var(--bg); border: 1px solid var(--border);
42
+ border-radius: var(--radius); color: var(--text); font-size: .9rem;
43
+ padding: .6rem .9rem; outline: none; transition: border-color .15s;
 
 
 
 
 
 
44
  }
45
  .input-row input[type="text"]:focus { border-color: var(--accent); }
46
  .input-row input[type="text"]::placeholder { color: var(--muted); }
47
 
48
  .btn {
49
+ background: var(--accent); border: none; border-radius: var(--radius);
50
+ color: #fff; cursor: pointer; font-size: .9rem; font-weight: 600;
51
+ padding: .6rem 1.2rem; transition: opacity .15s; white-space: nowrap;
 
 
 
 
 
 
 
52
  }
53
+ .btn:hover { opacity: .85; }
54
+ .btn:disabled { opacity: .4; cursor: not-allowed; }
55
 
56
  /* ---- drop zone ---- */
57
  #drop-zone {
58
+ border: 2px dashed var(--border); border-radius: var(--radius);
59
+ color: var(--muted); cursor: pointer; font-size: .85rem;
60
+ padding: 1.4rem; text-align: center;
61
+ transition: border-color .15s, background .15s; margin-bottom: 1rem;
 
 
 
 
 
 
 
 
 
62
  }
63
+ #drop-zone.drag-over { border-color: var(--accent); background: rgba(124,106,247,.06); }
64
  #drop-zone input[type="file"] { display: none; }
65
 
66
+ /* ---- global options ---- */
67
  .options-row {
68
+ display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
69
+ margin-bottom: 1.2rem; font-size: .85rem; color: var(--muted);
 
 
 
 
 
70
  }
71
+ .options-row label { display: flex; align-items: center; gap: .4rem; }
72
+ .options-row input[type="number"] {
73
+ background: var(--bg); border: 1px solid var(--border);
74
+ border-radius: 6px; color: var(--text); padding: .3rem .5rem;
75
+ width: 70px; font-size: .85rem;
 
 
 
 
 
76
  }
77
  .options-row input[type="range"] {
78
+ width: 120px; accent-color: var(--accent); cursor: pointer;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  }
80
+ #global-thresh-val { color: var(--text); min-width: 3.5ch; font-size: .85rem; }
 
 
 
 
 
 
81
 
82
+ /* ---- spinner / error ---- */
83
+ .spinner {
84
+ display: none; width: 22px; height: 22px;
85
+ border: 3px solid var(--border); border-top-color: var(--accent);
86
+ border-radius: 50%; animation: spin .7s linear infinite; margin: 1rem auto;
87
  }
88
+ @keyframes spin { to { transform: rotate(360deg); } }
89
+ .error-msg { color: #f87171; font-size: .85rem; margin-top: .5rem; display: none; }
90
+
91
+ /* ---- results ---- */
92
+ #results-area { display: none; margin-top: 1rem; }
93
 
94
+ /* preview — full width on top */
95
  .preview-wrap {
96
+ display: flex; align-items: flex-start; gap: 1rem;
97
+ margin-bottom: 1.2rem;
98
  }
99
  .preview-wrap img {
100
+ border-radius: var(--radius); max-height: 420px;
101
+ width: 100%; object-fit: contain;
102
+ border: 1px solid var(--border); display: block;
 
 
 
103
  }
104
+ .img-meta { color: var(--muted); font-size: .72rem; margin-top: .4rem; word-break: break-all; }
 
 
 
 
 
 
 
105
 
106
+ /* global copy bar */
107
+ .copy-bar { display: flex; align-items: center; gap: .5rem; margin-bottom: 1rem; }
 
 
 
 
108
  .tag-string {
109
+ background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
110
+ color: var(--muted); font-size: .78rem; flex: 1; padding: .4rem .6rem;
111
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
112
+ cursor: pointer; transition: border-color .15s;
 
 
 
 
 
 
 
 
113
  }
114
  .tag-string:hover { border-color: var(--accent); color: var(--text); }
115
  .copy-btn {
116
+ background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
117
+ color: var(--muted); cursor: pointer; font-size: .78rem;
118
+ padding: .35rem .6rem; transition: border-color .15s, color .15s; white-space: nowrap;
 
 
 
 
 
 
119
  }
120
+ .copy-btn:hover { border-color: var(--accent); color: #a78bfa; }
121
  .copy-btn.copied { color: var(--green); border-color: var(--green); }
122
 
123
+ /* ---- category sections ---- */
124
+ .categories { display: flex; flex-direction: column; gap: .8rem; }
125
+ .cat-section { border-radius: 8px; overflow: hidden; }
 
 
126
 
127
+ .cat-header {
128
+ display: flex; align-items: center; gap: .5rem;
129
+ padding: .45rem .7rem; cursor: pointer; user-select: none;
130
+ font-size: .8rem; font-weight: 600; letter-spacing: .03em;
 
 
 
 
 
131
  }
132
+ .cat-header:hover { filter: brightness(1.1); }
133
+ .cat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
134
+ .cat-name { flex: 1; }
135
+ .cat-chevron { transition: transform .2s; font-size: .7rem; margin-left: .2rem; }
136
+ .cat-section.collapsed .cat-chevron { transform: rotate(-90deg); }
137
+
138
+ /* per-category filter controls inside the header */
139
+ .cat-controls {
140
+ display: flex; align-items: center; gap: .4rem;
141
+ font-size: .75rem; font-weight: 400;
142
  }
143
+ .cat-controls .mode-mini {
144
+ display: flex; border-radius: 6px; overflow: hidden;
145
+ border: 1px solid rgba(255,255,255,.15);
146
+ }
147
+ .cat-controls .mode-mini button {
148
+ background: none; border: none; cursor: pointer;
149
+ font-size: .7rem; padding: .15rem .45rem; color: rgba(255,255,255,.45);
150
+ transition: background .12s, color .12s;
151
+ }
152
+ .cat-controls .mode-mini button.active {
153
+ background: rgba(255,255,255,.18); color: #fff;
154
+ }
155
+ .cat-controls input[type="number"] {
156
+ width: 52px; background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.15);
157
+ border-radius: 5px; color: #fff; font-size: .72rem; padding: .15rem .35rem;
158
+ text-align: center;
159
+ }
160
+ .cat-controls input[type="range"] {
161
+ width: 70px; accent-color: currentColor; cursor: pointer;
162
+ }
163
+ .cat-thresh-val { min-width: 2.8ch; }
164
+ .cat-thresh-num {
165
+ width: 44px; background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.15);
166
+ border-radius: 5px; color: #fff; font-size: .72rem; padding: .15rem .3rem;
167
+ text-align: center;
168
+ }
169
+ .cat-count {
170
+ background: rgba(0,0,0,.25); border-radius: 10px;
171
+ padding: .1rem .45rem; font-size: .72rem; white-space: nowrap;
172
  }
 
173
 
174
+ .cat-body { padding: .5rem .7rem .7rem; }
175
+ .cat-section.collapsed .cat-body { display: none; }
176
+
177
+ /* per-category copy bar */
178
+ .cat-copy-bar { display: flex; align-items: center; gap: .4rem; margin-bottom: .5rem; }
179
+ .cat-tag-string {
180
+ background: rgba(0,0,0,.2); border: 1px solid rgba(255,255,255,.08);
181
+ border-radius: 5px; color: rgba(255,255,255,.5); font-size: .72rem;
182
+ flex: 1; padding: .3rem .5rem; white-space: nowrap; overflow: hidden;
183
+ text-overflow: ellipsis; cursor: pointer;
184
+ }
185
+ .cat-tag-string:hover { color: rgba(255,255,255,.85); }
186
+ .cat-copy-btn {
187
+ background: rgba(0,0,0,.2); border: 1px solid rgba(255,255,255,.1);
188
+ border-radius: 5px; color: rgba(255,255,255,.4); cursor: pointer;
189
+ font-size: .72rem; padding: .25rem .5rem; white-space: nowrap;
190
+ transition: color .15s, border-color .15s;
191
  }
192
+ .cat-copy-btn:hover { color: rgba(255,255,255,.8); }
193
+ .cat-copy-btn.copied { color: var(--green); border-color: var(--green); }
194
 
195
+ /* tag pills */
196
+ .tag-list { display: flex; flex-wrap: wrap; gap: .35rem; }
197
+ .tag-pill {
198
+ display: inline-flex; align-items: center; gap: .3rem;
199
+ border-radius: 20px; font-size: .76rem; padding: .22rem .6rem;
200
+ cursor: default; transition: opacity .1s;
201
  }
202
+ .tag-pill:hover { opacity: .8; }
203
+ .tag-pill .score { font-size: .66rem; opacity: .7; }
204
+ .tag-pill.hidden { display: none; }
205
  </style>
206
  </head>
207
  <body>
 
211
 
212
  <div class="card">
213
 
 
214
  <div class="input-row">
215
  <input type="text" id="url-input" placeholder="Paste image URL or drop a file below…" />
216
  <button class="btn" id="tag-btn" onclick="runFromUrl()">Tag</button>
217
  </div>
218
 
 
219
  <div id="drop-zone" onclick="document.getElementById('file-input').click()">
220
  <input type="file" id="file-input" accept="image/*" onchange="runFromFile(this)" />
221
  Drop image here or <strong>click to browse</strong>
222
  </div>
223
 
 
224
  <div class="options-row">
225
+ <label>Max px <input type="number" id="maxsize-input" value="1024" min="64" max="4096" step="16" /></label>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  <label>
227
+ Min score
228
+ <input type="range" id="global-thresh" min="0" max="99" step="1" value="0"
229
+ oninput="setGlobalThreshold(this.value)" />
230
+ <input type="number" id="global-thresh-num" min="0" max="99" step="1" value="0"
231
+ style="width:52px"
232
+ oninput="setGlobalThreshold(this.value)" />
233
+ <span id="global-thresh-val" style="display:none">0%</span>
234
  </label>
235
  </div>
236
 
237
  <div class="spinner" id="spinner"></div>
238
  <div class="error-msg" id="error-msg"></div>
239
 
 
240
  <div id="results-area">
241
+
242
+ <!-- image full-width on top -->
243
+ <div class="preview-wrap">
244
+ <div style="width:100%">
245
  <img id="preview-img" src="" alt="preview" />
246
  <div class="img-meta" id="img-meta"></div>
247
  </div>
 
 
 
 
 
 
 
248
  </div>
249
+
250
+ <!-- global copy bar -->
251
+ <div class="copy-bar">
252
+ <div class="tag-string" id="tag-string" onclick="copyAll()" title="Click to copy all visible tags"></div>
253
+ <button class="copy-btn" id="copy-btn-all" onclick="copyAll()">Copy all</button>
254
+ </div>
255
+
256
+ <!-- category sections -->
257
+ <div class="categories" id="categories"></div>
258
+
259
  </div>
260
 
261
  </div>
262
 
263
  <script>
264
+ // ---- category metadata from server ----
265
+ const CAT_META = {
266
+ {% for cat_id, meta in category_meta.items() %}
267
+ {{ cat_id }}: { name: "{{ meta.name }}", color: "{{ meta.color }}" },
268
+ {% endfor %}
269
+ };
270
+
271
+ // Per-category filter state: { [catId]: { mode: 'topk'|'threshold', topk: int, threshold: float } }
272
+ const catState = {};
273
+ let globalFloor = 0.0; // global minimum score (0–1), applied on top of per-category filters
274
+
275
+ function getCatState(catId) {
276
+ if (!catState[catId]) catState[catId] = { mode: 'threshold', topk: 20, threshold: 0.85 };
277
+ return catState[catId];
278
+ }
279
+
280
+ function setGlobalThreshold(pct) {
281
+ const v = Math.max(0, Math.min(99, parseInt(pct) || 0));
282
+ globalFloor = v / 100;
283
+ document.getElementById('global-thresh').value = v;
284
+ document.getElementById('global-thresh-num').value = v;
285
+ // re-apply filter to every rendered category
286
+ document.querySelectorAll('.cat-section').forEach(sec => {
287
+ applyFilter(parseInt(sec.dataset.catId));
288
+ });
289
+ updateGlobalCopyBar();
290
+ }
291
+
292
+ function syncCatThreshNum(catId, pct) {
293
+ const el = document.getElementById(`thnum-${catId}`);
294
+ if (el) el.value = pct;
295
+ }
296
+ function syncCatThreshRange(catId, pct) {
297
+ const el = document.getElementById(`range-${catId}`);
298
+ if (el) el.value = Math.max(1, Math.min(99, parseInt(pct) || 1));
299
  }
300
 
301
  // ---- drag & drop ----
 
303
  dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag-over'); });
304
  dz.addEventListener('dragleave', () => dz.classList.remove('drag-over'));
305
  dz.addEventListener('drop', e => {
306
+ e.preventDefault(); dz.classList.remove('drag-over');
 
307
  const file = e.dataTransfer.files[0];
308
  if (file) submitFile(file);
309
  });
310
 
311
+ function runFromFile(input) { if (input.files[0]) submitFile(input.files[0]); }
 
 
312
 
313
  function runFromUrl() {
314
  const url = document.getElementById('url-input').value.trim();
315
  if (!url) return;
316
+ setPreview(url, url);
317
+ submitFetch(`/tag/url?max_size=${document.getElementById('maxsize-input').value}&url=${encodeURIComponent(url)}`,
318
+ { method: 'POST' });
 
 
 
 
 
 
 
 
 
 
 
319
  }
320
 
321
  function submitFile(file) {
322
+ const maxSize = document.getElementById('maxsize-input').value;
323
  const fd = new FormData();
324
  fd.append('file', file);
 
 
 
325
  const reader = new FileReader();
326
  reader.onload = e => setPreview(e.target.result, file.name);
327
  reader.readAsDataURL(file);
328
+ submitFetch(`/tag/upload?max_size=${maxSize}`, { method: 'POST', body: fd });
 
 
 
 
 
 
329
  }
330
 
331
  function submitFetch(url, opts) {
 
339
 
340
  function setPreview(src, label) {
341
  document.getElementById('preview-img').src = src;
342
+ document.getElementById('img-meta').textContent =
343
+ label.length > 80 ? '…' + label.slice(-77) : label;
344
  }
345
 
346
+ // ---- rendering ----
347
+
348
+ // All tags from the last response, grouped by category
349
+ let _lastData = null;
350
+
351
  function renderResults(data) {
352
  hideError();
353
+ _lastData = data;
354
+
355
+ const container = document.getElementById('categories');
356
+ container.innerHTML = '';
357
+
358
+ for (const cat of data.categories) {
359
+ const meta = CAT_META[cat.id] || { name: String(cat.id), color: '#6b7280' };
360
+ const color = meta.color;
361
+ const state = getCatState(cat.id);
362
+
363
+ const sec = document.createElement('div');
364
+ sec.className = 'cat-section';
365
+ sec.dataset.catId = cat.id;
366
+
367
+ // ---- header with inline controls ----
368
+ const hdr = document.createElement('div');
369
+ hdr.className = 'cat-header';
370
+ hdr.style.background = color + '22';
371
+ hdr.style.color = color;
372
+
373
+ hdr.innerHTML = `
374
+ <span class="cat-dot" style="background:${color}"></span>
375
+ <span class="cat-name">${meta.name}</span>
376
+
377
+ <span class="cat-controls" onclick="event.stopPropagation()">
378
+ <span class="mode-mini">
379
+ <button id="btn-topk-${cat.id}"
380
+ class="${state.mode==='topk'?'active':''}"
381
+ onclick="setCatMode(${cat.id},'topk')">top-k</button>
382
+ <button id="btn-thresh-${cat.id}"
383
+ class="${state.mode==='threshold'?'active':''}"
384
+ onclick="setCatMode(${cat.id},'threshold')">≥</button>
385
+ </span>
386
+
387
+ <span id="ctrl-topk-${cat.id}" style="display:${state.mode==='topk'?'flex':'none'};align-items:center;gap:.3rem">
388
+ <input type="number" id="topk-${cat.id}" value="${state.topk}" min="1" max="500"
389
+ style="width:52px"
390
+ oninput="setCatTopk(${cat.id},this.value)" />
391
+ </span>
392
+
393
+ <span id="ctrl-thresh-${cat.id}" style="display:${state.mode==='threshold'?'flex':'none'};align-items:center;gap:.3rem">
394
+ <input type="range" id="range-${cat.id}" min="1" max="99" step="1"
395
+ value="${Math.round(state.threshold*100)}"
396
+ oninput="setCatThreshold(${cat.id},this.value/100);syncCatThreshNum(${cat.id},this.value)" />
397
+ <input type="number" class="cat-thresh-num" id="thnum-${cat.id}"
398
+ min="1" max="99" step="1" value="${Math.round(state.threshold*100)}"
399
+ oninput="setCatThreshold(${cat.id},this.value/100);syncCatThreshRange(${cat.id},this.value)" />
400
+ <span class="cat-thresh-val" style="display:none" id="thval-${cat.id}">${state.threshold.toFixed(2)}</span>
401
+ </span>
402
+ </span>
403
+
404
+ <span class="cat-count" id="count-${cat.id}">0</span>
405
+ <span class="cat-chevron">▾</span>
406
+ `;
407
+ hdr.addEventListener('click', () => sec.classList.toggle('collapsed'));
408
+
409
+ // ---- body ----
410
+ const body = document.createElement('div');
411
+ body.className = 'cat-body';
412
+ body.style.background = color + '0d';
413
+ body.innerHTML = `
414
+ <div class="cat-copy-bar">
415
+ <div class="cat-tag-string" id="cat-copy-${cat.id}"
416
+ onclick="copyCat(${cat.id})" title="Click to copy"></div>
417
+ <button class="cat-copy-btn" id="cat-btn-${cat.id}"
418
+ onclick="copyCat(${cat.id})">Copy</button>
419
+ </div>
420
+ <div class="tag-list" id="cat-tags-${cat.id}"></div>
421
+ `;
422
+
423
+ // build all pills (hidden ones will be toggled by applyFilter)
424
+ const pillContainer = body.querySelector(`#cat-tags-${cat.id}`);
425
+ for (const t of cat.tags) {
426
+ const pill = document.createElement('span');
427
+ pill.className = 'tag-pill';
428
+ pill.style.background = color + '1a';
429
+ pill.style.border = `1px solid ${color}44`;
430
+ pill.style.color = color;
431
+ pill.dataset.score = t.score;
432
+ pill.dataset.tag = t.tag;
433
+ pill.title = `${t.tag}: ${t.score}`;
434
+ pill.innerHTML = `${t.tag}<span class="score">${(t.score*100).toFixed(0)}%</span>`;
435
+ pillContainer.appendChild(pill);
436
+ }
437
+
438
+ sec.appendChild(hdr);
439
+ sec.appendChild(body);
440
+ container.appendChild(sec);
441
+
442
+ applyFilter(cat.id);
443
+ }
444
 
445
  document.getElementById('results-area').style.display = 'block';
446
+ updateGlobalCopyBar();
447
+ }
448
+
449
+ // Apply topk or threshold filter to a category's pills
450
+ function applyFilter(catId) {
451
+ const state = getCatState(catId);
452
+ const pills = document.querySelectorAll(`#cat-tags-${catId} .tag-pill`);
453
+ if (!pills.length) return;
454
+
455
+ let visible = 0;
456
+ pills.forEach((pill, idx) => {
457
+ const score = parseFloat(pill.dataset.score);
458
+ let show;
459
+ if (state.mode === 'topk') {
460
+ show = idx < state.topk;
461
+ } else {
462
+ show = score >= state.threshold;
463
+ }
464
+ // global floor is always applied on top
465
+ if (score < globalFloor) show = false;
466
+ pill.classList.toggle('hidden', !show);
467
+ if (show) visible++;
468
+ });
469
+
470
+ // update count badge
471
+ const countEl = document.getElementById(`count-${catId}`);
472
+ if (countEl) countEl.textContent = visible;
473
+
474
+ // update copy bar text
475
+ const visibleTags = [...pills]
476
+ .filter(p => !p.classList.contains('hidden'))
477
+ .map(p => p.dataset.tag);
478
+ const copyEl = document.getElementById(`cat-copy-${catId}`);
479
+ if (copyEl) copyEl.textContent = visibleTags.join(', ');
480
+ }
481
+
482
+ function setCatMode(catId, mode) {
483
+ const state = getCatState(catId);
484
+ state.mode = mode;
485
+ document.getElementById(`btn-topk-${catId}`).classList.toggle('active', mode === 'topk');
486
+ document.getElementById(`btn-thresh-${catId}`).classList.toggle('active', mode === 'threshold');
487
+ document.getElementById(`ctrl-topk-${catId}`).style.display = mode === 'topk' ? 'flex' : 'none';
488
+ document.getElementById(`ctrl-thresh-${catId}`).style.display = mode === 'threshold' ? 'flex' : 'none';
489
+ applyFilter(catId);
490
+ updateGlobalCopyBar();
491
+ }
492
+
493
+ function setCatTopk(catId, val) {
494
+ getCatState(catId).topk = Math.max(1, parseInt(val) || 1);
495
+ applyFilter(catId);
496
+ updateGlobalCopyBar();
497
  }
498
 
499
+ function setCatThreshold(catId, val) {
500
+ const v = parseFloat(val);
501
+ getCatState(catId).threshold = v;
502
+ const el = document.getElementById(`thval-${catId}`);
503
+ if (el) el.textContent = v.toFixed(2);
504
+ applyFilter(catId);
505
+ updateGlobalCopyBar();
506
+ }
507
+
508
+ function updateGlobalCopyBar() {
509
+ // collect all visible tags across all categories, in category order
510
+ const allVisible = [];
511
+ document.querySelectorAll('.cat-section').forEach(sec => {
512
+ sec.querySelectorAll('.tag-pill:not(.hidden)').forEach(p => {
513
+ allVisible.push(p.dataset.tag);
514
+ });
515
+ });
516
+ document.getElementById('tag-string').textContent = allVisible.join(', ');
517
+ }
518
+
519
+ function copyAll() {
520
  const text = document.getElementById('tag-string').textContent;
521
  navigator.clipboard.writeText(text).then(() => {
522
+ const btn = document.getElementById('copy-btn-all');
523
+ btn.textContent = 'Copied!'; btn.classList.add('copied');
524
+ setTimeout(() => { btn.textContent = 'Copy all'; btn.classList.remove('copied'); }, 1800);
525
+ });
526
+ }
527
+
528
+ function copyCat(catId) {
529
+ const text = document.getElementById(`cat-copy-${catId}`).textContent;
530
+ navigator.clipboard.writeText(text).then(() => {
531
+ const btn = document.getElementById(`cat-btn-${catId}`);
532
+ btn.textContent = 'Copied!'; btn.classList.add('copied');
533
  setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1800);
534
  });
535
  }
 
542
 
543
  function showError(msg) {
544
  const el = document.getElementById('error-msg');
545
+ el.textContent = msg; el.style.display = 'block';
 
 
 
 
546
  }
547
+ function hideError() { document.getElementById('error-msg').style.display = 'none'; }
548
 
 
549
  document.getElementById('url-input').addEventListener('keydown', e => {
550
  if (e.key === 'Enter') runFromUrl();
551
  });