| <!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0" /> |
| <title>DINOv3 Tagger</title> |
| <style> |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } |
| |
| :root { |
| --bg: #0f0f11; |
| --surface: #1a1a1f; |
| --border: #2e2e38; |
| --accent: #7c6af7; |
| --text: #e2e2e8; |
| --muted: #6b6b7e; |
| --green: #4ade80; |
| --radius: 10px; |
| } |
| |
| body { |
| background: var(--bg); color: var(--text); |
| font-family: 'Inter', system-ui, sans-serif; |
| min-height: 100vh; display: flex; flex-direction: column; |
| align-items: center; padding: 2rem 1rem 4rem; |
| } |
| |
| h1 { font-size: 1.6rem; font-weight: 700; letter-spacing: -.02em; margin-bottom: .25rem; } |
| h1 span { color: #a78bfa; } |
| .subtitle { color: var(--muted); font-size: .85rem; margin-bottom: 2rem; } |
| |
| .card { |
| background: var(--surface); border: 1px solid var(--border); |
| border-radius: var(--radius); padding: 1.5rem; |
| width: 100%; max-width: 900px; |
| } |
| |
| |
| .input-row { display: flex; gap: .5rem; margin-bottom: 1rem; } |
| .input-row input[type="text"] { |
| flex: 1; background: var(--bg); border: 1px solid var(--border); |
| border-radius: var(--radius); color: var(--text); font-size: .9rem; |
| padding: .6rem .9rem; outline: none; transition: border-color .15s; |
| } |
| .input-row input[type="text"]:focus { border-color: var(--accent); } |
| .input-row input[type="text"]::placeholder { color: var(--muted); } |
| |
| .btn { |
| background: var(--accent); border: none; border-radius: var(--radius); |
| color: #fff; cursor: pointer; font-size: .9rem; font-weight: 600; |
| padding: .6rem 1.2rem; transition: opacity .15s; white-space: nowrap; |
| } |
| .btn:hover { opacity: .85; } |
| .btn:disabled { opacity: .4; cursor: not-allowed; } |
| |
| |
| #drop-zone { |
| border: 2px dashed var(--border); border-radius: var(--radius); |
| color: var(--muted); cursor: pointer; font-size: .85rem; |
| padding: 1.4rem; text-align: center; |
| transition: border-color .15s, background .15s; margin-bottom: 1rem; |
| } |
| #drop-zone.drag-over { border-color: var(--accent); background: rgba(124,106,247,.06); } |
| #drop-zone input[type="file"] { display: none; } |
| |
| |
| .options-row { |
| display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; |
| margin-bottom: 1.2rem; font-size: .85rem; color: var(--muted); |
| } |
| .options-row label { display: flex; align-items: center; gap: .4rem; } |
| .options-row input[type="number"] { |
| background: var(--bg); border: 1px solid var(--border); |
| border-radius: 6px; color: var(--text); padding: .3rem .5rem; |
| width: 70px; font-size: .85rem; |
| } |
| .options-row input[type="range"] { |
| width: 120px; accent-color: var(--accent); cursor: pointer; |
| } |
| #global-thresh-val { color: var(--text); min-width: 3.5ch; font-size: .85rem; } |
| |
| |
| .spinner { |
| display: none; width: 22px; height: 22px; |
| border: 3px solid var(--border); border-top-color: var(--accent); |
| border-radius: 50%; animation: spin .7s linear infinite; margin: 1rem auto; |
| } |
| @keyframes spin { to { transform: rotate(360deg); } } |
| .error-msg { color: #f87171; font-size: .85rem; margin-top: .5rem; display: none; } |
| |
| |
| #results-area { display: none; margin-top: 1rem; } |
| |
| |
| .preview-wrap { |
| display: flex; align-items: flex-start; gap: 1rem; |
| margin-bottom: 1.2rem; |
| } |
| .preview-wrap img { |
| border-radius: var(--radius); max-height: 420px; |
| width: 100%; object-fit: contain; |
| border: 1px solid var(--border); display: block; |
| } |
| .img-meta { color: var(--muted); font-size: .72rem; margin-top: .4rem; word-break: break-all; } |
| |
| |
| .copy-bar { display: flex; align-items: center; gap: .5rem; margin-bottom: 1rem; } |
| .tag-string { |
| background: var(--bg); border: 1px solid var(--border); border-radius: 6px; |
| color: var(--muted); font-size: .78rem; flex: 1; padding: .4rem .6rem; |
| white-space: nowrap; overflow: hidden; text-overflow: ellipsis; |
| cursor: pointer; transition: border-color .15s; |
| } |
| .tag-string:hover { border-color: var(--accent); color: var(--text); } |
| .copy-btn { |
| background: var(--bg); border: 1px solid var(--border); border-radius: 6px; |
| color: var(--muted); cursor: pointer; font-size: .78rem; |
| padding: .35rem .6rem; transition: border-color .15s, color .15s; white-space: nowrap; |
| } |
| .copy-btn:hover { border-color: var(--accent); color: #a78bfa; } |
| .copy-btn.copied { color: var(--green); border-color: var(--green); } |
| |
| |
| .categories { display: flex; flex-direction: column; gap: .8rem; } |
| .cat-section { border-radius: 8px; overflow: hidden; } |
| |
| .cat-header { |
| display: flex; align-items: center; gap: .5rem; |
| padding: .45rem .7rem; cursor: pointer; user-select: none; |
| font-size: .8rem; font-weight: 600; letter-spacing: .03em; |
| } |
| .cat-header:hover { filter: brightness(1.1); } |
| .cat-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } |
| .cat-name { flex: 1; } |
| .cat-chevron { transition: transform .2s; font-size: .7rem; margin-left: .2rem; } |
| .cat-section.collapsed .cat-chevron { transform: rotate(-90deg); } |
| |
| |
| .cat-controls { |
| display: flex; align-items: center; gap: .4rem; |
| font-size: .75rem; font-weight: 400; |
| } |
| .cat-controls .mode-mini { |
| display: flex; border-radius: 6px; overflow: hidden; |
| border: 1px solid rgba(255,255,255,.15); |
| } |
| .cat-controls .mode-mini button { |
| background: none; border: none; cursor: pointer; |
| font-size: .7rem; padding: .15rem .45rem; color: rgba(255,255,255,.45); |
| transition: background .12s, color .12s; |
| } |
| .cat-controls .mode-mini button.active { |
| background: rgba(255,255,255,.18); color: #fff; |
| } |
| .cat-controls input[type="number"] { |
| width: 52px; background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.15); |
| border-radius: 5px; color: #fff; font-size: .72rem; padding: .15rem .35rem; |
| text-align: center; |
| } |
| .cat-controls input[type="range"] { |
| width: 70px; accent-color: currentColor; cursor: pointer; |
| } |
| .cat-thresh-val { min-width: 2.8ch; } |
| .cat-thresh-num { |
| width: 44px; background: rgba(0,0,0,.3); border: 1px solid rgba(255,255,255,.15); |
| border-radius: 5px; color: #fff; font-size: .72rem; padding: .15rem .3rem; |
| text-align: center; |
| } |
| .cat-count { |
| background: rgba(0,0,0,.25); border-radius: 10px; |
| padding: .1rem .45rem; font-size: .72rem; white-space: nowrap; |
| } |
| |
| .cat-body { padding: .5rem .7rem .7rem; } |
| .cat-section.collapsed .cat-body { display: none; } |
| |
| |
| .cat-copy-bar { display: flex; align-items: center; gap: .4rem; margin-bottom: .5rem; } |
| .cat-tag-string { |
| background: rgba(0,0,0,.2); border: 1px solid rgba(255,255,255,.08); |
| border-radius: 5px; color: rgba(255,255,255,.5); font-size: .72rem; |
| flex: 1; padding: .3rem .5rem; white-space: nowrap; overflow: hidden; |
| text-overflow: ellipsis; cursor: pointer; |
| } |
| .cat-tag-string:hover { color: rgba(255,255,255,.85); } |
| .cat-copy-btn { |
| background: rgba(0,0,0,.2); border: 1px solid rgba(255,255,255,.1); |
| border-radius: 5px; color: rgba(255,255,255,.4); cursor: pointer; |
| font-size: .72rem; padding: .25rem .5rem; white-space: nowrap; |
| transition: color .15s, border-color .15s; |
| } |
| .cat-copy-btn:hover { color: rgba(255,255,255,.8); } |
| .cat-copy-btn.copied { color: var(--green); border-color: var(--green); } |
| |
| |
| .tag-list { display: flex; flex-wrap: wrap; gap: .35rem; } |
| .tag-pill { |
| display: inline-flex; align-items: center; gap: .3rem; |
| border-radius: 20px; font-size: .76rem; padding: .22rem .6rem; |
| cursor: default; transition: opacity .1s; |
| } |
| .tag-pill:hover { opacity: .8; } |
| .tag-pill .score { font-size: .66rem; opacity: .7; } |
| .tag-pill.hidden { display: none; } |
| |
| |
| .preview-wrap { flex-wrap: wrap; } |
| .preview-col { flex: 1 1 0; min-width: 0; } |
| .pca-col { |
| flex: 1 1 0; min-width: 0; |
| display: flex; flex-direction: column; gap: .5rem; |
| } |
| .pca-label { |
| font-size: .72rem; color: var(--muted); text-align: center; |
| letter-spacing: .04em; text-transform: uppercase; |
| } |
| #pca-img { |
| border-radius: var(--radius); width: 100%; max-height: 420px; |
| object-fit: contain; border: 1px solid var(--border); |
| display: block; image-rendering: pixelated; |
| } |
| #pca-spinner { |
| display: none; width: 18px; height: 18px; margin: auto; |
| border: 3px solid var(--border); border-top-color: var(--accent); |
| border-radius: 50%; animation: spin .7s linear infinite; |
| } |
| .pca-toggle { |
| background: var(--bg); border: 1px solid var(--border); |
| border-radius: 6px; color: var(--muted); cursor: pointer; |
| font-size: .75rem; padding: .3rem .7rem; align-self: center; |
| transition: border-color .15s, color .15s; |
| } |
| .pca-toggle:hover { border-color: var(--accent); color: var(--text); } |
| .pca-toggle.active { border-color: var(--accent); color: #a78bfa; } |
| </style> |
| </head> |
| <body> |
|
|
| <h1>DINOv3 <span>Tagger</span></h1> |
| <p class="subtitle">ViT-H/16+ · {{ num_tags | format_number }} tags · {{ vocab_path }}</p> |
|
|
| <div class="card"> |
|
|
| <div class="input-row"> |
| <input type="text" id="url-input" placeholder="Paste image URL or drop a file below…" /> |
| <button class="btn" id="tag-btn" onclick="runFromUrl()">Tag</button> |
| </div> |
|
|
| <div id="drop-zone" onclick="document.getElementById('file-input').click()"> |
| <input type="file" id="file-input" accept="image/*" onchange="runFromFile(this)" /> |
| Drop image here or <strong>click to browse</strong> |
| </div> |
|
|
| <div class="options-row"> |
| <label>Max px <input type="number" id="maxsize-input" value="1024" min="64" max="4096" step="16" /></label> |
| <label> |
| Min score |
| <input type="range" id="global-thresh" min="0" max="99" step="1" value="0" |
| oninput="setGlobalThreshold(this.value)" /> |
| <input type="number" id="global-thresh-num" min="0" max="99" step="1" value="0" |
| style="width:52px" |
| oninput="setGlobalThreshold(this.value)" /> |
| <span id="global-thresh-val" style="display:none">0%</span> |
| </label> |
| </div> |
|
|
| <div class="spinner" id="spinner"></div> |
| <div class="error-msg" id="error-msg"></div> |
|
|
| <div id="results-area"> |
|
|
| |
| <div class="preview-wrap"> |
| <div class="preview-col"> |
| <img id="preview-img" src="" alt="preview" /> |
| <div class="img-meta" id="img-meta"></div> |
| </div> |
| <div class="pca-col" id="pca-col" style="display:none"> |
| <div class="pca-label">PCA · patch features (R=PC1, G=PC2, B=PC3)</div> |
| <div id="pca-spinner"></div> |
| <img id="pca-img" src="" alt="PCA" style="display:none" /> |
| </div> |
| </div> |
|
|
| |
| <div style="display:flex;justify-content:flex-end;margin-bottom:.6rem"> |
| <button class="pca-toggle" id="pca-toggle" onclick="togglePca()">Show PCA</button> |
| </div> |
|
|
| |
| <div class="copy-bar"> |
| <div class="tag-string" id="tag-string" onclick="copyAll()" title="Click to copy all visible tags"></div> |
| <button class="copy-btn" id="copy-btn-all" onclick="copyAll()">Copy all</button> |
| </div> |
|
|
| |
| <div class="categories" id="categories"></div> |
|
|
| </div> |
|
|
| </div> |
|
|
| <script> |
| |
| const CAT_META = { |
| {% for cat_id, meta in category_meta.items() %} |
| {{ cat_id }}: { name: "{{ meta.name }}", color: "{{ meta.color }}" }, |
| {% endfor %} |
| }; |
| |
| |
| const catState = {}; |
| let globalFloor = 0.0; |
| |
| function getCatState(catId) { |
| if (!catState[catId]) catState[catId] = { mode: 'threshold', topk: 20, threshold: 0.85 }; |
| return catState[catId]; |
| } |
| |
| function setGlobalThreshold(pct) { |
| const v = Math.max(0, Math.min(99, parseInt(pct) || 0)); |
| globalFloor = v / 100; |
| document.getElementById('global-thresh').value = v; |
| document.getElementById('global-thresh-num').value = v; |
| |
| document.querySelectorAll('.cat-section').forEach(sec => { |
| applyFilter(parseInt(sec.dataset.catId)); |
| }); |
| updateGlobalCopyBar(); |
| } |
| |
| function syncCatThreshNum(catId, pct) { |
| const el = document.getElementById(`thnum-${catId}`); |
| if (el) el.value = pct; |
| } |
| function syncCatThreshRange(catId, pct) { |
| const el = document.getElementById(`range-${catId}`); |
| if (el) el.value = Math.max(1, Math.min(99, parseInt(pct) || 1)); |
| } |
| |
| |
| let _pcaEnabled = false; |
| let _lastPcaRequest = null; |
| |
| function togglePca() { |
| _pcaEnabled = !_pcaEnabled; |
| const btn = document.getElementById('pca-toggle'); |
| btn.textContent = _pcaEnabled ? 'Hide PCA' : 'Show PCA'; |
| btn.classList.toggle('active', _pcaEnabled); |
| document.getElementById('pca-col').style.display = _pcaEnabled ? 'flex' : 'none'; |
| if (_pcaEnabled && _lastPcaRequest) runPca(_lastPcaRequest); |
| } |
| |
| function runPca(req) { |
| const spinner = document.getElementById('pca-spinner'); |
| const img = document.getElementById('pca-img'); |
| spinner.style.display = 'block'; |
| img.style.display = 'none'; |
| |
| const maxSize = document.getElementById('maxsize-input').value; |
| let fetchPromise; |
| if (req.type === 'url') { |
| fetchPromise = fetch( |
| `/pca/url?max_size=${maxSize}&url=${encodeURIComponent(req.url)}`, |
| { method: 'POST' } |
| ); |
| } else { |
| const fd = new FormData(); |
| fd.append('file', req.file); |
| fetchPromise = fetch(`/pca/upload?max_size=${maxSize}`, { method: 'POST', body: fd }); |
| } |
| |
| fetchPromise |
| .then(r => r.ok ? r.blob() : Promise.reject('PCA failed')) |
| .then(blob => { |
| img.src = URL.createObjectURL(blob); |
| img.style.display = 'block'; |
| }) |
| .catch(() => { img.style.display = 'none'; }) |
| .finally(() => { spinner.style.display = 'none'; }); |
| } |
| |
| |
| const dz = document.getElementById('drop-zone'); |
| dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag-over'); }); |
| dz.addEventListener('dragleave', () => dz.classList.remove('drag-over')); |
| dz.addEventListener('drop', e => { |
| e.preventDefault(); dz.classList.remove('drag-over'); |
| const file = e.dataTransfer.files[0]; |
| if (file) submitFile(file); |
| }); |
| |
| function runFromFile(input) { if (input.files[0]) submitFile(input.files[0]); } |
| |
| function runFromUrl() { |
| const url = document.getElementById('url-input').value.trim(); |
| if (!url) return; |
| setPreview(url, url); |
| _lastPcaRequest = { type: 'url', url }; |
| if (_pcaEnabled) runPca(_lastPcaRequest); |
| submitFetch(`/tag/url?max_size=${document.getElementById('maxsize-input').value}&url=${encodeURIComponent(url)}`, |
| { method: 'POST' }); |
| } |
| |
| function submitFile(file) { |
| const maxSize = document.getElementById('maxsize-input').value; |
| const fd = new FormData(); |
| fd.append('file', file); |
| const reader = new FileReader(); |
| reader.onload = e => setPreview(e.target.result, file.name); |
| reader.readAsDataURL(file); |
| _lastPcaRequest = { type: 'file', file }; |
| if (_pcaEnabled) runPca(_lastPcaRequest); |
| submitFetch(`/tag/upload?max_size=${maxSize}`, { method: 'POST', body: fd }); |
| } |
| |
| function submitFetch(url, opts) { |
| setLoading(true); |
| fetch(url, opts) |
| .then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.detail || 'Server error'))) |
| .then(renderResults) |
| .catch(err => showError(String(err))) |
| .finally(() => setLoading(false)); |
| } |
| |
| function setPreview(src, label) { |
| document.getElementById('preview-img').src = src; |
| document.getElementById('img-meta').textContent = |
| label.length > 80 ? '…' + label.slice(-77) : label; |
| } |
| |
| |
| |
| |
| let _lastData = null; |
| |
| function renderResults(data) { |
| hideError(); |
| _lastData = data; |
| |
| const container = document.getElementById('categories'); |
| container.innerHTML = ''; |
| |
| for (const cat of data.categories) { |
| const meta = CAT_META[cat.id] || { name: String(cat.id), color: '#6b7280' }; |
| const color = meta.color; |
| const state = getCatState(cat.id); |
| |
| const sec = document.createElement('div'); |
| sec.className = 'cat-section'; |
| sec.dataset.catId = cat.id; |
| |
| |
| const hdr = document.createElement('div'); |
| hdr.className = 'cat-header'; |
| hdr.style.background = color + '22'; |
| hdr.style.color = color; |
| |
| hdr.innerHTML = ` |
| <span class="cat-dot" style="background:${color}"></span> |
| <span class="cat-name">${meta.name}</span> |
| |
| <span class="cat-controls" onclick="event.stopPropagation()"> |
| <span class="mode-mini"> |
| <button id="btn-topk-${cat.id}" |
| class="${state.mode==='topk'?'active':''}" |
| onclick="setCatMode(${cat.id},'topk')">top-k</button> |
| <button id="btn-thresh-${cat.id}" |
| class="${state.mode==='threshold'?'active':''}" |
| onclick="setCatMode(${cat.id},'threshold')">≥</button> |
| </span> |
| |
| <span id="ctrl-topk-${cat.id}" style="display:${state.mode==='topk'?'flex':'none'};align-items:center;gap:.3rem"> |
| <input type="number" id="topk-${cat.id}" value="${state.topk}" min="1" max="500" |
| style="width:52px" |
| oninput="setCatTopk(${cat.id},this.value)" /> |
| </span> |
| |
| <span id="ctrl-thresh-${cat.id}" style="display:${state.mode==='threshold'?'flex':'none'};align-items:center;gap:.3rem"> |
| <input type="range" id="range-${cat.id}" min="1" max="99" step="1" |
| value="${Math.round(state.threshold*100)}" |
| oninput="setCatThreshold(${cat.id},this.value/100);syncCatThreshNum(${cat.id},this.value)" /> |
| <input type="number" class="cat-thresh-num" id="thnum-${cat.id}" |
| min="1" max="99" step="1" value="${Math.round(state.threshold*100)}" |
| oninput="setCatThreshold(${cat.id},this.value/100);syncCatThreshRange(${cat.id},this.value)" /> |
| <span class="cat-thresh-val" style="display:none" id="thval-${cat.id}">${state.threshold.toFixed(2)}</span> |
| </span> |
| </span> |
| |
| <span class="cat-count" id="count-${cat.id}">0</span> |
| <span class="cat-chevron">▾</span> |
| `; |
| hdr.addEventListener('click', () => sec.classList.toggle('collapsed')); |
| |
| |
| const body = document.createElement('div'); |
| body.className = 'cat-body'; |
| body.style.background = color + '0d'; |
| body.innerHTML = ` |
| <div class="cat-copy-bar"> |
| <div class="cat-tag-string" id="cat-copy-${cat.id}" |
| onclick="copyCat(${cat.id})" title="Click to copy"></div> |
| <button class="cat-copy-btn" id="cat-btn-${cat.id}" |
| onclick="copyCat(${cat.id})">Copy</button> |
| </div> |
| <div class="tag-list" id="cat-tags-${cat.id}"></div> |
| `; |
| |
| |
| const pillContainer = body.querySelector(`#cat-tags-${cat.id}`); |
| for (const t of cat.tags) { |
| const pill = document.createElement('span'); |
| pill.className = 'tag-pill'; |
| pill.style.background = color + '1a'; |
| pill.style.border = `1px solid ${color}44`; |
| pill.style.color = color; |
| pill.dataset.score = t.score; |
| pill.dataset.tag = t.tag; |
| pill.title = `${t.tag}: ${t.score}`; |
| pill.innerHTML = `${t.tag}<span class="score">${(t.score*100).toFixed(0)}%</span>`; |
| pillContainer.appendChild(pill); |
| } |
| |
| sec.appendChild(hdr); |
| sec.appendChild(body); |
| container.appendChild(sec); |
| |
| applyFilter(cat.id); |
| } |
| |
| document.getElementById('results-area').style.display = 'block'; |
| updateGlobalCopyBar(); |
| } |
| |
| |
| function applyFilter(catId) { |
| const state = getCatState(catId); |
| const pills = document.querySelectorAll(`#cat-tags-${catId} .tag-pill`); |
| if (!pills.length) return; |
| |
| let visible = 0; |
| pills.forEach((pill, idx) => { |
| const score = parseFloat(pill.dataset.score); |
| let show; |
| if (state.mode === 'topk') { |
| show = idx < state.topk; |
| } else { |
| show = score >= state.threshold; |
| } |
| |
| if (score < globalFloor) show = false; |
| pill.classList.toggle('hidden', !show); |
| if (show) visible++; |
| }); |
| |
| |
| const countEl = document.getElementById(`count-${catId}`); |
| if (countEl) countEl.textContent = visible; |
| |
| |
| const visibleTags = [...pills] |
| .filter(p => !p.classList.contains('hidden')) |
| .map(p => p.dataset.tag); |
| const copyEl = document.getElementById(`cat-copy-${catId}`); |
| if (copyEl) copyEl.textContent = visibleTags.join(', '); |
| } |
| |
| function setCatMode(catId, mode) { |
| const state = getCatState(catId); |
| state.mode = mode; |
| document.getElementById(`btn-topk-${catId}`).classList.toggle('active', mode === 'topk'); |
| document.getElementById(`btn-thresh-${catId}`).classList.toggle('active', mode === 'threshold'); |
| document.getElementById(`ctrl-topk-${catId}`).style.display = mode === 'topk' ? 'flex' : 'none'; |
| document.getElementById(`ctrl-thresh-${catId}`).style.display = mode === 'threshold' ? 'flex' : 'none'; |
| applyFilter(catId); |
| updateGlobalCopyBar(); |
| } |
| |
| function setCatTopk(catId, val) { |
| getCatState(catId).topk = Math.max(1, parseInt(val) || 1); |
| applyFilter(catId); |
| updateGlobalCopyBar(); |
| } |
| |
| function setCatThreshold(catId, val) { |
| const v = parseFloat(val); |
| getCatState(catId).threshold = v; |
| const el = document.getElementById(`thval-${catId}`); |
| if (el) el.textContent = v.toFixed(2); |
| applyFilter(catId); |
| updateGlobalCopyBar(); |
| } |
| |
| function updateGlobalCopyBar() { |
| |
| const allVisible = []; |
| document.querySelectorAll('.cat-section').forEach(sec => { |
| sec.querySelectorAll('.tag-pill:not(.hidden)').forEach(p => { |
| allVisible.push(p.dataset.tag); |
| }); |
| }); |
| document.getElementById('tag-string').textContent = allVisible.join(', '); |
| } |
| |
| function copyAll() { |
| const text = document.getElementById('tag-string').textContent; |
| navigator.clipboard.writeText(text).then(() => { |
| const btn = document.getElementById('copy-btn-all'); |
| btn.textContent = 'Copied!'; btn.classList.add('copied'); |
| setTimeout(() => { btn.textContent = 'Copy all'; btn.classList.remove('copied'); }, 1800); |
| }); |
| } |
| |
| function copyCat(catId) { |
| const text = document.getElementById(`cat-copy-${catId}`).textContent; |
| navigator.clipboard.writeText(text).then(() => { |
| const btn = document.getElementById(`cat-btn-${catId}`); |
| btn.textContent = 'Copied!'; btn.classList.add('copied'); |
| setTimeout(() => { btn.textContent = 'Copy'; btn.classList.remove('copied'); }, 1800); |
| }); |
| } |
| |
| function setLoading(on) { |
| document.getElementById('spinner').style.display = on ? 'block' : 'none'; |
| document.getElementById('tag-btn').disabled = on; |
| if (on) document.getElementById('results-area').style.display = 'none'; |
| } |
| |
| function showError(msg) { |
| const el = document.getElementById('error-msg'); |
| el.textContent = msg; el.style.display = 'block'; |
| } |
| function hideError() { document.getElementById('error-msg').style.display = 'none'; } |
| |
| document.getElementById('url-input').addEventListener('keydown', e => { |
| if (e.key === 'Enter') runFromUrl(); |
| }); |
| </script> |
| </body> |
| </html> |
|
|