A check asks each saved search in turn and writes back what it found, and the list was built again from scratch on every one of those writes. So a background check -- which has no business being visible at all beyond a new count and a new time -- replayed the whole list's entry animation, threw away the reader's scroll position, and took the pointer out of whatever row it was over, several times over. Rows are now matched by id and told what changed: the count, the name, the line underneath, nothing else. A row is only built when it is new, gone, or changes shape, and the entry animation belongs to a row that has arrived rather than to every row every time. One mid-rename or mid-confirm is left alone entirely, so a check can no longer type over the reader. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPZFnNdHbPGDTqQUNiE4ZN
1824 lines
78 KiB
JavaScript
1824 lines
78 KiB
JavaScript
window.App = window.App || {};
|
|
App.ui = App.ui || {};
|
|
|
|
(function() {
|
|
const state = App.state;
|
|
|
|
App.ui.applyTheme = function() {
|
|
const theme = localStorage.getItem('theme') || 'dark';
|
|
document.body.classList.toggle('theme-light', theme === 'light');
|
|
const select = document.getElementById('theme-select');
|
|
if (select) select.value = theme;
|
|
};
|
|
|
|
App.ui.applyPreferredQuality = function() {
|
|
const select = document.getElementById('quality-select');
|
|
if (select) select.value = App.storage.getPreferredQuality();
|
|
};
|
|
|
|
App.ui.applyFeedEndBehavior = function() {
|
|
const select = document.getElementById('feed-end-select');
|
|
if (select) select.value = App.storage.getFeedEndBehavior();
|
|
};
|
|
|
|
App.ui.applyDensity = function() {
|
|
const density = App.storage.getDensity();
|
|
document.body.dataset.density = density;
|
|
const select = document.getElementById('density-select');
|
|
if (select) select.value = density;
|
|
};
|
|
|
|
// Card Size: re-packs the virtual grid (column count derives from the scaled
|
|
// minimum card width in videos.js).
|
|
App.ui.applyCardScale = function() {
|
|
const scale = App.storage.getCardScale();
|
|
const range = document.getElementById('card-size-range');
|
|
if (range) range.value = scale;
|
|
if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') {
|
|
App.virtualGrid.relayout();
|
|
}
|
|
};
|
|
|
|
// Text Size: drives the --card-font-scale CSS variable; a re-pack follows so
|
|
// card heights account for the new text size.
|
|
App.ui.applyFontScale = function() {
|
|
const scale = App.storage.getFontScale();
|
|
document.documentElement.style.setProperty('--card-font-scale', scale);
|
|
const range = document.getElementById('text-size-range');
|
|
if (range) range.value = scale;
|
|
if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') {
|
|
App.virtualGrid.relayout();
|
|
}
|
|
};
|
|
|
|
// Toast helper for playback + network errors.
|
|
App.ui.showError = function(message) {
|
|
const toast = document.getElementById('error-toast');
|
|
const text = document.getElementById('error-toast-text');
|
|
if (!toast || !text) return;
|
|
text.textContent = message;
|
|
toast.classList.add('show');
|
|
if (state.errorToastTimer) {
|
|
clearTimeout(state.errorToastTimer);
|
|
}
|
|
state.errorToastTimer = setTimeout(() => {
|
|
toast.classList.remove('show');
|
|
}, 4000);
|
|
};
|
|
|
|
// Which video the panel is currently showing, so a slow resolve that lands
|
|
// after the user moved on doesn't redraw someone else's panel.
|
|
let infoVideo = null;
|
|
// Exactly what the panel is showing, as one object -- what the copy button
|
|
// hands over. Kept beside the rendering rather than rebuilt on click, so
|
|
// the two can't disagree about what "this video" means.
|
|
let infoPayload = null;
|
|
|
|
// The clipboard proper needs a secure context, which a home-screen app on
|
|
// https has and a plain-http LAN address does not -- hence the old
|
|
// execCommand path behind it, which only works on a selection in the
|
|
// document.
|
|
const copyText = async function(text) {
|
|
if (navigator.clipboard && window.isSecureContext) {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
return true;
|
|
} catch (err) { /* fall through to the old way */ }
|
|
}
|
|
const scratch = document.createElement('textarea');
|
|
scratch.value = text;
|
|
scratch.setAttribute('readonly', '');
|
|
// Off-screen but focusable: display:none or visibility:hidden would
|
|
// leave nothing to select, and a visible one would scroll the page.
|
|
scratch.style.position = 'fixed';
|
|
scratch.style.top = '-1000px';
|
|
scratch.style.opacity = '0';
|
|
document.body.appendChild(scratch);
|
|
try {
|
|
scratch.select();
|
|
return document.execCommand('copy');
|
|
} catch (err) {
|
|
return false;
|
|
} finally {
|
|
scratch.remove();
|
|
}
|
|
};
|
|
|
|
let copyResetTimer = null;
|
|
|
|
App.ui.copyInfoJson = async function() {
|
|
const button = document.getElementById('info-copy');
|
|
if (!infoPayload) return false;
|
|
const copied = await copyText(JSON.stringify(infoPayload, null, 2));
|
|
if (!copied) {
|
|
App.ui.showError('Could not copy to the clipboard.');
|
|
return false;
|
|
}
|
|
if (button) {
|
|
button.classList.add('is-copied');
|
|
button.textContent = 'Copied';
|
|
if (copyResetTimer) clearTimeout(copyResetTimer);
|
|
copyResetTimer = setTimeout(() => {
|
|
button.classList.remove('is-copied');
|
|
button.textContent = 'Copy JSON';
|
|
}, 1600);
|
|
}
|
|
return true;
|
|
};
|
|
|
|
const appendInfoHeading = function(list, label) {
|
|
const heading = document.createElement('div');
|
|
heading.className = 'info-section';
|
|
heading.textContent = label;
|
|
list.appendChild(heading);
|
|
};
|
|
|
|
// One row per field, whatever the field is. Objects and arrays are printed
|
|
// as JSON rather than summarised: the panel is the place to see exactly what
|
|
// the server said, so nothing is dropped or abbreviated here.
|
|
const appendInfoRows = function(list, data) {
|
|
let count = 0;
|
|
Object.entries(data || {}).forEach(([key, value]) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'info-row';
|
|
|
|
const label = document.createElement('span');
|
|
label.className = 'info-label';
|
|
label.textContent = key;
|
|
|
|
let valueNode;
|
|
if (value && typeof value === 'object') {
|
|
valueNode = document.createElement('pre');
|
|
valueNode.className = 'info-json';
|
|
valueNode.textContent = JSON.stringify(value, null, 2);
|
|
} else {
|
|
valueNode = document.createElement('span');
|
|
valueNode.className = 'info-value';
|
|
valueNode.textContent = value === undefined || value === null || value === '' ? '—' : String(value);
|
|
}
|
|
|
|
row.appendChild(label);
|
|
row.appendChild(valueNode);
|
|
list.appendChild(row);
|
|
count++;
|
|
});
|
|
return count;
|
|
};
|
|
|
|
// Shows every field the client holds for a video: the listing item's own
|
|
// (id, title, uploader, duration, tags, ...) and then the extractor's, which
|
|
// arrive separately. It used to show `video.meta` *instead of* the item once
|
|
// one had been resolved, which silently hid everything the listing knew the
|
|
// moment a card had been hovered.
|
|
// `options.info` is the full extractor payload (App.videos.fetchFullInfo);
|
|
// `options.pending` notes that it's still on its way.
|
|
App.ui.showInfo = function(video, options) {
|
|
const modal = document.getElementById('info-modal');
|
|
if (!modal) return;
|
|
const opts = options || {};
|
|
const title = document.getElementById('info-title');
|
|
const list = document.getElementById('info-list');
|
|
const empty = document.getElementById('info-empty');
|
|
|
|
const item = (video && typeof video === 'object') ? video : {};
|
|
// `meta` is the trimmed playback payload; the full extractor info is a
|
|
// superset of it, so only one of the two is ever shown.
|
|
const resolved = opts.info || item.meta || null;
|
|
|
|
if (title) title.textContent = item.title || (resolved && resolved.title) || 'Video Info';
|
|
|
|
// `meta` gets its own section below rather than a row of JSON.
|
|
const own = Object.assign({}, item);
|
|
delete own.meta;
|
|
const section = opts.info ? 'extractor' : 'resolved';
|
|
infoPayload = Object.assign({}, own);
|
|
if (resolved && typeof resolved === 'object') infoPayload[section] = resolved;
|
|
|
|
let rows = 0;
|
|
if (list) {
|
|
list.innerHTML = "";
|
|
rows += appendInfoRows(list, own);
|
|
|
|
if (resolved && typeof resolved === 'object') {
|
|
appendInfoHeading(list, opts.info ? 'Extractor' : 'Resolved');
|
|
rows += appendInfoRows(list, resolved);
|
|
}
|
|
|
|
if (opts.pending) {
|
|
const pending = document.createElement('div');
|
|
pending.className = 'info-pending';
|
|
pending.textContent = 'Resolving full metadata…';
|
|
list.appendChild(pending);
|
|
}
|
|
}
|
|
|
|
if (empty) {
|
|
empty.style.display = rows ? 'none' : 'block';
|
|
}
|
|
|
|
modal.classList.add('open');
|
|
modal.setAttribute('aria-hidden', 'false');
|
|
};
|
|
|
|
// Opens the panel on what the client already has, then redraws it with the
|
|
// extractor's full payload once that lands. Resolution runs yt-dlp against
|
|
// the source site and can take seconds; there's no reason to stare at
|
|
// nothing (or at a spinner) while it does.
|
|
App.ui.openInfo = function(video) {
|
|
infoVideo = video;
|
|
// A fresh panel, so the button stops saying it copied the last one.
|
|
const copyBtn = document.getElementById('info-copy');
|
|
if (copyBtn) {
|
|
if (copyResetTimer) clearTimeout(copyResetTimer);
|
|
copyBtn.classList.remove('is-copied');
|
|
copyBtn.textContent = 'Copy JSON';
|
|
}
|
|
const canResolve = !!(App.videos && typeof App.videos.fetchFullInfo === 'function');
|
|
App.ui.showInfo(video, { pending: canResolve });
|
|
if (!canResolve) return;
|
|
App.videos.fetchFullInfo(video).then((info) => {
|
|
if (infoVideo !== video) return; // the panel moved on, or closed
|
|
App.ui.showInfo(video, { info: info });
|
|
});
|
|
};
|
|
|
|
App.ui.closeInfo = function() {
|
|
const modal = document.getElementById('info-modal');
|
|
if (!modal) return;
|
|
infoVideo = null;
|
|
infoPayload = null;
|
|
modal.classList.remove('open');
|
|
modal.setAttribute('aria-hidden', 'true');
|
|
};
|
|
|
|
// Drawer controls shared by the inline HTML handlers.
|
|
App.ui.closeDrawers = function() {
|
|
const menuDrawer = document.getElementById('drawer-menu');
|
|
const settingsDrawer = document.getElementById('drawer-settings');
|
|
const overlay = document.getElementById('overlay');
|
|
const menuBtn = document.querySelector('.menu-toggle');
|
|
const settingsBtn = document.querySelector('.settings-toggle');
|
|
|
|
if (menuDrawer) menuDrawer.classList.remove('open');
|
|
if (settingsDrawer) settingsDrawer.classList.remove('open');
|
|
if (overlay) overlay.classList.remove('open');
|
|
if (menuBtn) menuBtn.classList.remove('active');
|
|
if (settingsBtn) settingsBtn.classList.remove('active');
|
|
document.body.classList.remove('drawer-open');
|
|
};
|
|
|
|
App.ui.toggleDrawer = function(type) {
|
|
const menuDrawer = document.getElementById('drawer-menu');
|
|
const settingsDrawer = document.getElementById('drawer-settings');
|
|
const overlay = document.getElementById('overlay');
|
|
const menuBtn = document.querySelector('.menu-toggle');
|
|
const settingsBtn = document.querySelector('.settings-toggle');
|
|
|
|
const isMenu = type === 'menu';
|
|
const targetDrawer = isMenu ? menuDrawer : settingsDrawer;
|
|
const otherDrawer = isMenu ? settingsDrawer : menuDrawer;
|
|
const targetBtn = isMenu ? menuBtn : settingsBtn;
|
|
const otherBtn = isMenu ? settingsBtn : menuBtn;
|
|
|
|
if (!targetDrawer || !overlay) return;
|
|
|
|
const willOpen = !targetDrawer.classList.contains('open');
|
|
|
|
if (otherDrawer) otherDrawer.classList.remove('open');
|
|
if (otherBtn) otherBtn.classList.remove('active');
|
|
|
|
if (willOpen) {
|
|
targetDrawer.classList.add('open');
|
|
if (targetBtn) targetBtn.classList.add('active');
|
|
overlay.classList.add('open');
|
|
document.body.classList.add('drawer-open');
|
|
} else {
|
|
App.ui.closeDrawers();
|
|
}
|
|
};
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Recent searches
|
|
//
|
|
// The searches from before, offered under the box as you type. Which ones
|
|
// and in what order is App.storage's business (suggestSearches); this is
|
|
// the list itself: when it shows, how it is walked, and what taking one
|
|
// does. Five at a time, because a list you have to read is not a shortcut.
|
|
// ---------------------------------------------------------------------
|
|
App.ui.searchHistory = (function() {
|
|
const SHOWN = 5;
|
|
let offered = [];
|
|
let activeIndex = -1;
|
|
let bound = false;
|
|
|
|
const input = () => document.getElementById('search-input');
|
|
const list = () => document.getElementById('search-suggest');
|
|
|
|
const isOpen = function() {
|
|
const el = list();
|
|
return !!el && !el.hidden;
|
|
};
|
|
|
|
const close = function() {
|
|
const el = list();
|
|
if (el) {
|
|
el.hidden = true;
|
|
el.textContent = ''; // no rows left behind for anything to find
|
|
}
|
|
offered = [];
|
|
activeIndex = -1;
|
|
const box = input();
|
|
if (box) {
|
|
box.setAttribute('aria-expanded', 'false');
|
|
box.removeAttribute('aria-activedescendant');
|
|
}
|
|
};
|
|
|
|
// The typed part, picked out of the suggestion it matched. Built as
|
|
// text nodes rather than markup: these strings are the reader's own,
|
|
// and they are going back on screen.
|
|
const rowLabel = function(entry, typed) {
|
|
const label = document.createElement('span');
|
|
label.className = 'search-suggest-label';
|
|
const at = typed ? entry.toLowerCase().indexOf(typed.toLowerCase()) : -1;
|
|
if (at < 0) {
|
|
label.textContent = entry;
|
|
return label;
|
|
}
|
|
label.appendChild(document.createTextNode(entry.slice(0, at)));
|
|
const hit = document.createElement('span');
|
|
hit.className = 'search-suggest-hit';
|
|
hit.textContent = entry.slice(at, at + typed.length);
|
|
label.appendChild(hit);
|
|
label.appendChild(document.createTextNode(entry.slice(at + typed.length)));
|
|
return label;
|
|
};
|
|
|
|
const highlight = function(next) {
|
|
const el = list();
|
|
if (!el) return;
|
|
const rows = Array.from(el.querySelectorAll('.search-suggest-row'));
|
|
activeIndex = next;
|
|
let activeId = '';
|
|
rows.forEach((row, i) => {
|
|
const on = i === activeIndex;
|
|
row.classList.toggle('is-active', on);
|
|
row.setAttribute('aria-selected', on ? 'true' : 'false');
|
|
if (on) activeId = row.id;
|
|
});
|
|
const box = input();
|
|
if (!box) return;
|
|
if (activeId) box.setAttribute('aria-activedescendant', activeId);
|
|
else box.removeAttribute('aria-activedescendant');
|
|
};
|
|
|
|
const choose = function(query) {
|
|
const box = input();
|
|
close();
|
|
if (!query) return;
|
|
if (box) {
|
|
box.value = query;
|
|
box.blur(); // the list has served its purpose; give the page back
|
|
}
|
|
App.videos.handleSearch(query);
|
|
};
|
|
|
|
// Shows what the box's current contents suggest, or hides the list when
|
|
// nothing does.
|
|
const refresh = function() {
|
|
const box = input();
|
|
const el = list();
|
|
if (!box || !el) return;
|
|
const typed = box.value.trim();
|
|
offered = App.storage.suggestSearches(typed, SHOWN);
|
|
if (!offered.length) {
|
|
close();
|
|
return;
|
|
}
|
|
el.textContent = '';
|
|
offered.forEach((entry, i) => {
|
|
const row = document.createElement('button');
|
|
row.type = 'button';
|
|
row.className = 'search-suggest-row';
|
|
row.id = 'search-suggest-' + i;
|
|
row.setAttribute('role', 'option');
|
|
row.setAttribute('aria-selected', 'false');
|
|
row.dataset.query = entry;
|
|
row.appendChild(rowLabel(entry, typed));
|
|
// Chosen on pointerdown, before the box loses focus: a click
|
|
// would arrive after the blur that closes the list.
|
|
row.addEventListener('pointerdown', (event) => {
|
|
event.preventDefault();
|
|
choose(entry);
|
|
});
|
|
row.addEventListener('mousemove', () => highlight(i));
|
|
el.appendChild(row);
|
|
});
|
|
el.hidden = false;
|
|
box.setAttribute('aria-expanded', 'true');
|
|
highlight(-1);
|
|
};
|
|
|
|
const onKeyDown = function(event) {
|
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
if (!isOpen()) {
|
|
refresh();
|
|
if (!isOpen()) return;
|
|
}
|
|
event.preventDefault();
|
|
const step = event.key === 'ArrowDown' ? 1 : -1;
|
|
const count = offered.length;
|
|
highlight(((activeIndex + step) % count + count) % count);
|
|
return;
|
|
}
|
|
if (event.key === 'Enter') {
|
|
const box = input();
|
|
if (isOpen() && activeIndex >= 0) {
|
|
event.preventDefault();
|
|
choose(offered[activeIndex]);
|
|
return;
|
|
}
|
|
// Typed and entered: a search the reader meant, so it is kept
|
|
// now rather than waiting for the typing to settle.
|
|
close();
|
|
if (box) {
|
|
App.storage.rememberSearch(box.value);
|
|
App.videos.handleSearch(box.value);
|
|
}
|
|
return;
|
|
}
|
|
if (event.key === 'Escape' && isOpen()) {
|
|
event.preventDefault();
|
|
event.stopPropagation(); // Escape closes drawers too
|
|
close();
|
|
}
|
|
};
|
|
|
|
const bind = function() {
|
|
const box = input();
|
|
if (bound || !box) return;
|
|
bound = true;
|
|
box.addEventListener('focus', refresh);
|
|
box.addEventListener('input', refresh);
|
|
box.addEventListener('keydown', onKeyDown);
|
|
box.addEventListener('blur', () => { window.setTimeout(close, 0); });
|
|
document.addEventListener('pointerdown', (event) => {
|
|
if (!isOpen()) return;
|
|
if (event.target.closest('.search-container')) return;
|
|
close();
|
|
});
|
|
};
|
|
|
|
return { bind: bind, refresh: refresh, close: close, isOpen: isOpen, shown: () => offered.slice() };
|
|
})();
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Saved searches
|
|
//
|
|
// The bookmark in the search bar keeps whatever is on screen -- source,
|
|
// channel, filters, query -- and the bookmark in the header opens the
|
|
// list of them. Each row carries what is new in it since it was last
|
|
// opened (App.savedSearches does the counting), and tapping a row puts
|
|
// that whole search back.
|
|
// ---------------------------------------------------------------------
|
|
App.ui.savedSearches = (function() {
|
|
let bound = false;
|
|
// A row with its rename field open, or its "remove?" question showing.
|
|
// Held here so a check landing mid-edit redraws around it rather than
|
|
// through it.
|
|
let pending = null;
|
|
|
|
const el = (id) => document.getElementById(id);
|
|
const panel = () => el('saved-panel');
|
|
const isOpen = () => !!panel() && panel().classList.contains('open');
|
|
|
|
const ago = function(stamp) {
|
|
if (!stamp) return 'never';
|
|
const seconds = Math.max(0, Math.round((Date.now() - stamp) / 1000));
|
|
if (seconds < 90) return 'just now';
|
|
const minutes = Math.round(seconds / 60);
|
|
if (minutes < 60) return `${minutes} min ago`;
|
|
const hours = Math.round(minutes / 60);
|
|
if (hours < 24) return `${hours} h ago`;
|
|
const days = Math.round(hours / 24);
|
|
return days === 1 ? 'yesterday' : `${days} days ago`;
|
|
};
|
|
|
|
// Where the search points, in one line: the channel, what the filters
|
|
// narrow it to, and the words themselves.
|
|
const metaLine = function(entry) {
|
|
const parts = [entry.channelName || entry.channelId];
|
|
if (entry.optionLabels && entry.optionLabels.length) {
|
|
parts.push(entry.optionLabels.slice(0, 3).join(' · '));
|
|
}
|
|
parts.push(entry.query ? `“${entry.query}”` : 'no search term');
|
|
return parts.join(' · ');
|
|
};
|
|
|
|
const stampLine = function(entry) {
|
|
if (entry.unreachable) return 'Could not be checked — the source did not answer.';
|
|
const checked = `Checked ${ago(entry.lastCheckedAt)}`;
|
|
if (!entry.newCount) return `${checked} · nothing new`;
|
|
const since = entry.lastViewedAt ? ` since you opened it ${ago(entry.lastViewedAt)}` : '';
|
|
const label = App.savedSearches.badgeLabel(entry.newCount);
|
|
return `${label} new${since} · ${checked.toLowerCase()}`;
|
|
};
|
|
|
|
const svgIcon = function(paths, extraClass) {
|
|
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
svg.setAttribute('viewBox', '0 0 24 24');
|
|
svg.setAttribute('aria-hidden', 'true');
|
|
svg.setAttribute('class', 'icon-line' + (extraClass ? ' ' + extraClass : ''));
|
|
paths.forEach((d) => {
|
|
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
path.setAttribute('d', d);
|
|
svg.appendChild(path);
|
|
});
|
|
return svg;
|
|
};
|
|
|
|
const RENAME_ICON = ['M4.5 19.5h3L19 8a2.1 2.1 0 0 0-3-3L4.5 16.5v3z'];
|
|
const DELETE_ICON = ['M5 7.5h14', 'M9.5 7.5V5.75h5V7.5', 'M7 7.5l.8 11.2h8.4L17 7.5'];
|
|
|
|
const toolButton = function(act, label, paths) {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'saved-tool';
|
|
button.dataset.act = act;
|
|
button.title = label;
|
|
button.setAttribute('aria-label', label);
|
|
button.appendChild(svgIcon(paths));
|
|
return button;
|
|
};
|
|
|
|
// A row mid-rename: the name becomes a field, and leaving it (Enter or
|
|
// blur) keeps what was typed. Escape puts the old name back -- the
|
|
// normal way round for an edit in place.
|
|
const renameField = function(entry, row) {
|
|
const field = document.createElement('input');
|
|
field.type = 'text';
|
|
field.className = 'saved-rename';
|
|
field.value = entry.name;
|
|
field.maxLength = 80;
|
|
field.setAttribute('aria-label', 'Name for this saved search');
|
|
let settled = false;
|
|
const commit = function(keep) {
|
|
if (settled) return;
|
|
settled = true;
|
|
pending = null;
|
|
if (keep) App.savedSearches.rename(entry.id, field.value);
|
|
else sync();
|
|
};
|
|
field.addEventListener('keydown', (event) => {
|
|
event.stopPropagation();
|
|
if (event.key === 'Enter') { event.preventDefault(); commit(true); }
|
|
else if (event.key === 'Escape') { event.preventDefault(); commit(false); }
|
|
});
|
|
field.addEventListener('blur', () => commit(true));
|
|
field.addEventListener('click', (event) => event.stopPropagation());
|
|
row.classList.add('is-renaming');
|
|
return field;
|
|
};
|
|
|
|
const confirmStrip = function(entry) {
|
|
const strip = document.createElement('div');
|
|
strip.className = 'saved-confirm';
|
|
|
|
const question = document.createElement('span');
|
|
question.className = 'saved-confirm-text';
|
|
question.textContent = 'Remove this saved search?';
|
|
|
|
const remove = document.createElement('button');
|
|
remove.type = 'button';
|
|
remove.className = 'saved-confirm-btn is-danger';
|
|
remove.textContent = 'Remove';
|
|
remove.addEventListener('click', () => {
|
|
pending = null;
|
|
App.savedSearches.remove(entry.id);
|
|
});
|
|
|
|
const keep = document.createElement('button');
|
|
keep.type = 'button';
|
|
keep.className = 'saved-confirm-btn';
|
|
keep.textContent = 'Keep';
|
|
keep.addEventListener('click', () => { pending = null; sync(); });
|
|
|
|
strip.appendChild(question);
|
|
strip.appendChild(keep);
|
|
strip.appendChild(remove);
|
|
return strip;
|
|
};
|
|
|
|
// Which of the three shapes a row is in. A row only has to be built
|
|
// again when it changes shape; otherwise the one on screen is updated
|
|
// where it stands (see render).
|
|
const modeOf = function(entry) {
|
|
return (pending && pending.id === entry.id) ? pending.mode : 'plain';
|
|
};
|
|
|
|
const buildRow = function(entry, index) {
|
|
const row = document.createElement('div');
|
|
// Only a row that was not on screen a moment ago animates in.
|
|
row.className = 'saved-row is-new';
|
|
row.dataset.id = entry.id;
|
|
row.dataset.mode = modeOf(entry);
|
|
row.style.setProperty('--i', String(index));
|
|
if (entry.unreachable) row.classList.add('is-unreachable');
|
|
|
|
const main = document.createElement('button');
|
|
main.type = 'button';
|
|
main.className = 'saved-row-main';
|
|
|
|
const count = document.createElement('span');
|
|
const label = App.savedSearches.badgeLabel(entry.newCount);
|
|
count.className = 'saved-count' + (label ? '' : ' is-quiet');
|
|
// The quiet state is a mark, not a number: a row with nothing new
|
|
// should not look like a row with something in it.
|
|
count.textContent = label || '·';
|
|
if (label) count.title = `${entry.newCount} new since you last opened this`;
|
|
|
|
const text = document.createElement('span');
|
|
text.className = 'saved-row-text';
|
|
|
|
const name = document.createElement('span');
|
|
name.className = 'saved-row-name';
|
|
name.textContent = entry.name;
|
|
|
|
const meta = document.createElement('span');
|
|
meta.className = 'saved-row-meta';
|
|
meta.textContent = metaLine(entry);
|
|
|
|
const stamp = document.createElement('span');
|
|
stamp.className = 'saved-row-stamp';
|
|
stamp.textContent = stampLine(entry);
|
|
|
|
text.appendChild(name);
|
|
text.appendChild(meta);
|
|
text.appendChild(stamp);
|
|
main.appendChild(count);
|
|
main.appendChild(text);
|
|
main.addEventListener('click', () => choose(entry.id));
|
|
row.appendChild(main);
|
|
|
|
if (pending && pending.id === entry.id && pending.mode === 'rename') {
|
|
const field = renameField(entry, row);
|
|
name.replaceWith(field);
|
|
main.disabled = true;
|
|
requestAnimationFrame(() => { field.focus(); field.select(); });
|
|
return row;
|
|
}
|
|
|
|
if (pending && pending.id === entry.id && pending.mode === 'delete') {
|
|
row.classList.add('is-confirming');
|
|
row.appendChild(confirmStrip(entry));
|
|
return row;
|
|
}
|
|
|
|
const tools = document.createElement('div');
|
|
tools.className = 'saved-row-tools';
|
|
const rename = toolButton('rename', `Rename ${entry.name}`, RENAME_ICON);
|
|
rename.addEventListener('click', () => { pending = { id: entry.id, mode: 'rename' }; render(); });
|
|
const remove = toolButton('delete', `Remove ${entry.name}`, DELETE_ICON);
|
|
remove.addEventListener('click', () => { pending = { id: entry.id, mode: 'delete' }; render(); });
|
|
tools.appendChild(rename);
|
|
tools.appendChild(remove);
|
|
row.appendChild(tools);
|
|
// What updateRow writes to, so it doesn't have to search the row
|
|
// again on every check.
|
|
row._parts = { count: count, name: name, meta: meta, stamp: stamp,
|
|
rename: rename, remove: remove };
|
|
return row;
|
|
};
|
|
|
|
// The same row, told what changed. A check writes a new count and a new
|
|
// "checked" time on every saved search it asks about; rebuilding the
|
|
// list for that would play the whole list's entry animation again,
|
|
// drop the reader's scroll position and take the pointer out of
|
|
// whatever it was over. A background check has no business doing any
|
|
// of that, so nothing is replaced -- the few strings that differ are
|
|
// written in place.
|
|
const updateRow = function(row, entry, index) {
|
|
const parts = row._parts;
|
|
if (!parts) return row;
|
|
row.classList.remove('is-new');
|
|
row.style.setProperty('--i', String(index));
|
|
row.classList.toggle('is-unreachable', !!entry.unreachable);
|
|
|
|
const label = App.savedSearches.badgeLabel(entry.newCount);
|
|
const countText = label || '·';
|
|
if (parts.count.textContent !== countText) parts.count.textContent = countText;
|
|
parts.count.classList.toggle('is-quiet', !label);
|
|
if (label) parts.count.title = `${entry.newCount} new since you last opened this`;
|
|
else parts.count.removeAttribute('title');
|
|
|
|
if (parts.name.textContent !== entry.name) {
|
|
parts.name.textContent = entry.name;
|
|
parts.rename.title = `Rename ${entry.name}`;
|
|
parts.rename.setAttribute('aria-label', `Rename ${entry.name}`);
|
|
parts.remove.title = `Remove ${entry.name}`;
|
|
parts.remove.setAttribute('aria-label', `Remove ${entry.name}`);
|
|
}
|
|
const meta = metaLine(entry);
|
|
if (parts.meta.textContent !== meta) parts.meta.textContent = meta;
|
|
const stamp = stampLine(entry);
|
|
if (parts.stamp.textContent !== stamp) parts.stamp.textContent = stamp;
|
|
return row;
|
|
};
|
|
|
|
const render = function() {
|
|
const list = el('saved-list');
|
|
const empty = el('saved-empty');
|
|
const note = el('saved-head-note');
|
|
const add = el('saved-add-btn');
|
|
const check = el('saved-check-btn');
|
|
const read = el('saved-read-btn');
|
|
if (!list) return;
|
|
|
|
const entries = App.savedSearches.getAll();
|
|
const onScreen = new Map();
|
|
Array.from(list.children).forEach((row) => onScreen.set(row.dataset.id, row));
|
|
entries.forEach((entry, index) => {
|
|
const had = onScreen.get(entry.id);
|
|
onScreen.delete(entry.id);
|
|
// A row mid-rename or mid-confirm is a different row: it has
|
|
// its own field and buttons, so that one is built.
|
|
let row;
|
|
if (had && had.dataset.mode === modeOf(entry)) {
|
|
row = updateRow(had, entry, index);
|
|
} else {
|
|
row = buildRow(entry, index);
|
|
// A row that changed shape where it stood has not arrived
|
|
// either -- it is the same row, asking something.
|
|
if (had) row.classList.remove('is-new');
|
|
}
|
|
if (list.children[index] !== row) {
|
|
list.insertBefore(row, list.children[index] || null);
|
|
}
|
|
});
|
|
onScreen.forEach((row) => row.remove());
|
|
if (empty) empty.hidden = entries.length > 0;
|
|
|
|
if (note) {
|
|
const total = App.savedSearches.totalNew();
|
|
note.textContent = !entries.length ?
|
|
'Keep a search once, come back to it in one tap.' :
|
|
(total ?
|
|
`${App.savedSearches.badgeLabel(total)} new since you last looked.` :
|
|
'Checked once a day. Nothing new right now.');
|
|
}
|
|
if (check) {
|
|
check.disabled = !entries.length || App.savedSearches.isChecking();
|
|
check.textContent = App.savedSearches.isChecking() ? 'Checking…' : 'Check now';
|
|
}
|
|
if (read) {
|
|
const marking = App.savedSearches.isMarkingAllRead();
|
|
// Nothing to clear is not a button worth pressing.
|
|
read.disabled = marking || !App.savedSearches.totalNew();
|
|
read.textContent = marking ? 'Marking…' : 'Mark all read';
|
|
}
|
|
if (add) {
|
|
const match = App.savedSearches.findMatching();
|
|
add.disabled = !!match || !App.savedSearches.current();
|
|
add.textContent = match ? `Already saved as “${match.name}”` : 'Save the current search';
|
|
}
|
|
};
|
|
|
|
const choose = function(id) {
|
|
close();
|
|
App.savedSearches.apply(id);
|
|
};
|
|
|
|
const open = function() {
|
|
const box = panel();
|
|
if (!box) return;
|
|
pending = null;
|
|
render();
|
|
box.classList.add('open');
|
|
box.setAttribute('aria-hidden', 'false');
|
|
const trigger = el('saved-searches-btn');
|
|
if (trigger) trigger.setAttribute('aria-expanded', 'true');
|
|
// Opening the list is as good a moment as any to find out whether
|
|
// anything is due -- the daily check may have been missed by a tab
|
|
// that was never left open that long.
|
|
App.savedSearches.check();
|
|
const closeBtn = el('saved-close-btn');
|
|
if (closeBtn) requestAnimationFrame(() => closeBtn.focus());
|
|
};
|
|
|
|
const close = function() {
|
|
const box = panel();
|
|
// Where the keyboard goes next: back to the button that opened
|
|
// this, not to the top of the page behind it.
|
|
const returnFocus = !!(box && box.contains(document.activeElement));
|
|
if (box) {
|
|
box.classList.remove('open');
|
|
box.setAttribute('aria-hidden', 'true');
|
|
}
|
|
pending = null;
|
|
const trigger = el('saved-searches-btn');
|
|
if (trigger) {
|
|
trigger.setAttribute('aria-expanded', 'false');
|
|
if (returnFocus) trigger.focus();
|
|
}
|
|
};
|
|
|
|
// The bookmark in the search bar: lit when what is on screen is
|
|
// already saved, and pressing it then takes it off the list again.
|
|
const toggleCurrent = function() {
|
|
const match = App.savedSearches.findMatching();
|
|
if (match) {
|
|
App.savedSearches.remove(match.id);
|
|
return;
|
|
}
|
|
const saved = App.savedSearches.saveCurrent();
|
|
if (!saved) return;
|
|
const button = el('search-save-btn');
|
|
if (button) {
|
|
button.classList.remove('is-just-saved');
|
|
// Restarted rather than left to run: saving twice in a row
|
|
// should flash twice.
|
|
void button.offsetWidth;
|
|
button.classList.add('is-just-saved');
|
|
}
|
|
};
|
|
|
|
// Keeps every piece of the feature showing the same thing: the
|
|
// bookmark's state, the header's count, and the list if it is open.
|
|
const sync = function() {
|
|
const button = el('search-save-btn');
|
|
if (button) {
|
|
const match = App.savedSearches.findMatching();
|
|
const available = !!App.savedSearches.current();
|
|
button.classList.toggle('is-saved', !!match);
|
|
button.setAttribute('aria-pressed', match ? 'true' : 'false');
|
|
button.disabled = !available;
|
|
const title = match ? `Saved as “${match.name}” — tap to remove` : 'Save this search';
|
|
button.title = title;
|
|
button.setAttribute('aria-label', title);
|
|
}
|
|
|
|
const badge = el('saved-searches-badge');
|
|
const trigger = el('saved-searches-btn');
|
|
if (badge) {
|
|
const total = App.savedSearches.totalNew();
|
|
const label = App.savedSearches.badgeLabel(total);
|
|
badge.textContent = label;
|
|
badge.hidden = !label;
|
|
if (trigger) {
|
|
const count = App.savedSearches.getAll().length;
|
|
trigger.setAttribute('aria-label', label ?
|
|
`Saved searches, ${total > App.savedSearches.NEW_CAP ? 'more than ' + App.savedSearches.NEW_CAP : total} new` :
|
|
(count ? `Saved searches (${count})` : 'Saved searches'));
|
|
}
|
|
}
|
|
|
|
// A rename in progress is the reader's text, not ours to redraw.
|
|
if (isOpen() && !(pending && pending.mode === 'rename')) render();
|
|
};
|
|
|
|
const bind = function() {
|
|
if (bound) return;
|
|
const box = panel();
|
|
const trigger = el('saved-searches-btn');
|
|
if (!box || !trigger) return;
|
|
bound = true;
|
|
|
|
trigger.addEventListener('click', () => { if (isOpen()) close(); else open(); });
|
|
const closeBtn = el('saved-close-btn');
|
|
if (closeBtn) closeBtn.addEventListener('click', close);
|
|
box.addEventListener('click', (event) => { if (event.target === box) close(); });
|
|
|
|
const saveBtn = el('search-save-btn');
|
|
if (saveBtn) saveBtn.addEventListener('click', toggleCurrent);
|
|
|
|
const add = el('saved-add-btn');
|
|
if (add) {
|
|
add.addEventListener('click', () => {
|
|
const saved = App.savedSearches.saveCurrent();
|
|
if (saved) pending = { id: saved.id, mode: 'rename' };
|
|
render();
|
|
});
|
|
}
|
|
|
|
const check = el('saved-check-btn');
|
|
if (check) {
|
|
check.addEventListener('click', () => {
|
|
App.savedSearches.check({ force: true });
|
|
render();
|
|
});
|
|
}
|
|
|
|
const read = el('saved-read-btn');
|
|
if (read) {
|
|
read.addEventListener('click', () => {
|
|
App.savedSearches.markAllRead();
|
|
render();
|
|
});
|
|
}
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape' && isOpen()) {
|
|
event.stopPropagation();
|
|
close();
|
|
}
|
|
});
|
|
};
|
|
|
|
return {
|
|
bind: bind,
|
|
sync: sync,
|
|
open: open,
|
|
close: close,
|
|
isOpen: isOpen,
|
|
toggleCurrent: toggleCurrent
|
|
};
|
|
})();
|
|
|
|
// ---------------------------------------------------------------------
|
|
// Channel picker
|
|
//
|
|
// A <select> of ninety channels tells the reader almost nothing: a wall of
|
|
// bare names, nothing to say which site a name belongs to or what it
|
|
// carries, and no way to look for one. The server sends far more than the
|
|
// name -- a favicon, a description, tags, whether the channel still says
|
|
// "work in progress" -- so the picker shows that, and lets the reader type.
|
|
//
|
|
// The <select> was also where the command palette read its channel actions
|
|
// from, so the list lives here now and the palette asks for it.
|
|
// ---------------------------------------------------------------------
|
|
App.ui.channels = (function() {
|
|
// Sections in display order: a group, then whatever the server didn't
|
|
// put in one. Rebuilt whenever the menu renders, which is whenever the
|
|
// server, its status, or the selection changes.
|
|
let sections = [];
|
|
let matched = []; // the rows the current search leaves on screen
|
|
let activeIndex = 0;
|
|
let bound = false;
|
|
|
|
const el = (id) => document.getElementById(id);
|
|
|
|
const activeServerData = function() {
|
|
const session = App.storage.getSession();
|
|
if (!session) return null;
|
|
const entry = App.storage.getServerEntries()
|
|
.find((candidate) => candidate.url === session.server);
|
|
return (entry && entry.data) || null;
|
|
};
|
|
|
|
// Everything about a channel worth searching, in one string: a reader
|
|
// typing "jav", "leaks" or the site's own name should all land.
|
|
const haystack = function(parts) {
|
|
return parts.filter(Boolean).join(' ').toLowerCase();
|
|
};
|
|
|
|
const channelRow = function(channel, groupTitle) {
|
|
const tags = Array.isArray(channel.tags) ? channel.tags : [];
|
|
return {
|
|
id: channel.id,
|
|
name: channel.name || channel.id,
|
|
note: channel.description || '',
|
|
favicon: channel.favicon || '',
|
|
tags: tags.slice(0, 3),
|
|
// "work in progress" is the server's own word for a channel
|
|
// that may not answer; worth saying before it's picked.
|
|
flag: channel.status && channel.status !== 'active' ? channel.status :
|
|
(channel.premium ? 'premium' : ''),
|
|
group: groupTitle || '',
|
|
search: haystack([channel.name, channel.id, channel.description,
|
|
tags.join(' '), groupTitle])
|
|
};
|
|
};
|
|
|
|
const build = function() {
|
|
const data = activeServerData();
|
|
const channels = (data && Array.isArray(data.channels)) ? data.channels : [];
|
|
const groups = (data && Array.isArray(data.channelGroups)) ? data.channelGroups : [];
|
|
const byId = new Map(channels.map((channel) => [channel.id, channel]));
|
|
const grouped = new Set();
|
|
sections = [];
|
|
|
|
groups.forEach((group) => {
|
|
const ids = (Array.isArray(group.channelIds) ? group.channelIds : [])
|
|
.filter((id) => byId.has(id));
|
|
if (!ids.length) return;
|
|
ids.forEach((id) => grouped.add(id));
|
|
const title = group.title || group.id;
|
|
sections.push({
|
|
title: title,
|
|
rows: [{
|
|
id: `group:${group.id}`,
|
|
name: `All ${title}`,
|
|
// Every group row would otherwise be an "A" for "All".
|
|
mark: title,
|
|
note: `Every channel in ${title}, interleaved.`,
|
|
favicon: '',
|
|
tags: [],
|
|
flag: ids.length === 1 ? '1 channel' : `${ids.length} channels`,
|
|
group: title,
|
|
search: haystack(['all', title, group.id])
|
|
}].concat(ids.map((id) => channelRow(byId.get(id), title)))
|
|
});
|
|
});
|
|
|
|
const ungrouped = channels
|
|
.filter((channel) => !grouped.has(channel.id))
|
|
.sort((a, b) => (a.name || a.id || '').toLowerCase()
|
|
.localeCompare((b.name || b.id || '').toLowerCase()));
|
|
if (ungrouped.length) {
|
|
sections.push({
|
|
title: sections.length ? 'Everything else' : 'Channels',
|
|
rows: ungrouped.map((channel) => channelRow(channel, ''))
|
|
});
|
|
}
|
|
};
|
|
|
|
// The letter behind a favicon that hasn't arrived (or never will), so a
|
|
// row is never a name next to an empty square.
|
|
const markFor = function(row) {
|
|
const mark = document.createElement('span');
|
|
mark.className = 'channel-mark';
|
|
mark.dataset.letter = (row.mark || row.name || '?').trim().charAt(0).toUpperCase();
|
|
if (row.favicon) {
|
|
const img = document.createElement('img');
|
|
img.className = 'channel-favicon';
|
|
img.alt = '';
|
|
img.loading = 'lazy';
|
|
img.decoding = 'async';
|
|
mark.appendChild(img);
|
|
// The same route race, proxy fallback and retries every other
|
|
// remote picture in the app goes through.
|
|
App.videos.attachThumbnail(img, row.favicon);
|
|
}
|
|
return mark;
|
|
};
|
|
|
|
const rowButton = function(row, currentId) {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'channel-row' + (row.id === currentId ? ' is-current' : '');
|
|
button.setAttribute('role', 'option');
|
|
button.setAttribute('aria-selected', row.id === currentId ? 'true' : 'false');
|
|
button.dataset.channelId = row.id;
|
|
button.appendChild(markFor(row));
|
|
|
|
const text = document.createElement('span');
|
|
text.className = 'channel-row-text';
|
|
const name = document.createElement('span');
|
|
name.className = 'channel-row-name';
|
|
name.textContent = row.name;
|
|
text.appendChild(name);
|
|
if (row.note) {
|
|
const note = document.createElement('span');
|
|
note.className = 'channel-row-note';
|
|
note.textContent = row.note;
|
|
text.appendChild(note);
|
|
}
|
|
if (row.tags.length) {
|
|
const tags = document.createElement('span');
|
|
tags.className = 'channel-row-tags';
|
|
row.tags.forEach((tag) => {
|
|
const chip = document.createElement('span');
|
|
chip.className = 'channel-tag';
|
|
chip.textContent = tag;
|
|
tags.appendChild(chip);
|
|
});
|
|
text.appendChild(tags);
|
|
}
|
|
button.appendChild(text);
|
|
|
|
if (row.flag) {
|
|
const flag = document.createElement('span');
|
|
flag.className = 'channel-row-flag';
|
|
flag.textContent = row.flag;
|
|
button.appendChild(flag);
|
|
}
|
|
return button;
|
|
};
|
|
|
|
const render = function() {
|
|
const list = el('channel-picker-list');
|
|
const empty = el('channel-picker-empty');
|
|
const search = el('channel-search');
|
|
if (!list) return;
|
|
const query = (search ? search.value : '').trim().toLowerCase();
|
|
const session = App.storage.getSession();
|
|
const currentId = (session && session.channel) ? session.channel.id : '';
|
|
|
|
list.innerHTML = '';
|
|
matched = [];
|
|
sections.forEach((section) => {
|
|
const rows = query
|
|
? section.rows.filter((row) => row.search.includes(query))
|
|
: section.rows;
|
|
if (!rows.length) return;
|
|
const heading = document.createElement('div');
|
|
heading.className = 'channel-section';
|
|
heading.textContent = section.title;
|
|
list.appendChild(heading);
|
|
rows.forEach((row) => {
|
|
const button = rowButton(row, currentId);
|
|
const index = matched.length;
|
|
button.addEventListener('click', () => App.ui.channels.choose(row.id));
|
|
button.addEventListener('pointermove', () => setActive(index));
|
|
list.appendChild(button);
|
|
matched.push(button);
|
|
});
|
|
});
|
|
if (empty) empty.hidden = matched.length > 0;
|
|
// A shorter list under an unchanged scroll position hides its own
|
|
// first hits, so every search starts back at the top.
|
|
if (query) list.scrollTop = 0;
|
|
// A search starts on its first hit; an unsearched list starts on the
|
|
// channel already being read, which is also what gets scrolled to.
|
|
const current = matched.findIndex((button) => button.classList.contains('is-current'));
|
|
setActive(query || current < 0 ? 0 : current, !query);
|
|
};
|
|
|
|
const setActive = function(index, scroll) {
|
|
if (!matched.length) { activeIndex = 0; return; }
|
|
activeIndex = Math.max(0, Math.min(index, matched.length - 1));
|
|
matched.forEach((button, i) => button.classList.toggle('is-active', i === activeIndex));
|
|
if (scroll && matched[activeIndex]) {
|
|
matched[activeIndex].scrollIntoView({ block: 'center' });
|
|
}
|
|
};
|
|
|
|
const step = function(delta) {
|
|
setActive(activeIndex + delta);
|
|
const button = matched[activeIndex];
|
|
if (button) button.scrollIntoView({ block: 'nearest' });
|
|
};
|
|
|
|
const open = function() {
|
|
const picker = el('channel-picker');
|
|
const search = el('channel-search');
|
|
const trigger = el('channel-picker-btn');
|
|
if (!picker) return;
|
|
build();
|
|
if (search) search.value = '';
|
|
// Shown before it is filled: scrolling the current channel into
|
|
// view can't work while the list is still display:none.
|
|
picker.classList.add('open');
|
|
picker.setAttribute('aria-hidden', 'false');
|
|
render();
|
|
if (trigger) trigger.setAttribute('aria-expanded', 'true');
|
|
// Typing is the point of the thing -- but not on a phone, where
|
|
// focusing the field throws up the keyboard over the list.
|
|
if (search && window.matchMedia('(min-width: 720px)').matches) {
|
|
requestAnimationFrame(() => search.focus());
|
|
}
|
|
};
|
|
|
|
const close = function() {
|
|
const picker = el('channel-picker');
|
|
const trigger = el('channel-picker-btn');
|
|
if (picker) {
|
|
picker.classList.remove('open');
|
|
picker.setAttribute('aria-hidden', 'true');
|
|
}
|
|
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
|
};
|
|
|
|
const bind = function() {
|
|
if (bound) return;
|
|
const picker = el('channel-picker');
|
|
const trigger = el('channel-picker-btn');
|
|
const search = el('channel-search');
|
|
const closeBtn = el('channel-picker-close');
|
|
if (!picker || !trigger) return;
|
|
bound = true;
|
|
|
|
trigger.addEventListener('click', open);
|
|
if (closeBtn) closeBtn.addEventListener('click', close);
|
|
picker.addEventListener('click', (event) => { if (event.target === picker) close(); });
|
|
if (search) {
|
|
search.addEventListener('input', render);
|
|
search.addEventListener('keydown', (event) => {
|
|
if (event.key === 'ArrowDown') { event.preventDefault(); step(1); }
|
|
else if (event.key === 'ArrowUp') { event.preventDefault(); step(-1); }
|
|
else if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
const button = matched[activeIndex];
|
|
if (button) button.click();
|
|
} else if (event.key === 'Escape') { event.preventDefault(); close(); }
|
|
});
|
|
}
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape' && picker.classList.contains('open')) close();
|
|
});
|
|
};
|
|
|
|
// The trigger says what is being read now, with the same favicon the
|
|
// list shows, so the menu answers the question without being opened.
|
|
const renderTrigger = function() {
|
|
const session = App.storage.getSession();
|
|
const channel = session && session.channel;
|
|
const name = el('channel-trigger-name');
|
|
const note = el('channel-trigger-note');
|
|
const mark = el('channel-trigger-mark');
|
|
const icon = el('channel-trigger-icon');
|
|
if (!name || !mark || !icon) return;
|
|
name.textContent = channel ? (channel.name || channel.id) : 'No channel';
|
|
mark.dataset.letter = (channel ? (channel.name || channel.id || '?') : '?')
|
|
.trim().charAt(0).toUpperCase();
|
|
if (note) {
|
|
note.textContent = channel
|
|
? (channel.isGroup ? 'Whole group' : (channel.description || ''))
|
|
: 'This source has no channels.';
|
|
}
|
|
App.videos.detachThumbnail(icon);
|
|
icon.removeAttribute('src');
|
|
icon.hidden = !(channel && channel.favicon);
|
|
if (channel && channel.favicon) App.videos.attachThumbnail(icon, channel.favicon);
|
|
};
|
|
|
|
return {
|
|
// Called by renderMenu: the picker follows whatever the menu is
|
|
// showing, and nothing else has to know it exists.
|
|
render: function() {
|
|
bind();
|
|
build();
|
|
renderTrigger();
|
|
if (el('channel-picker') && el('channel-picker').classList.contains('open')) {
|
|
render();
|
|
}
|
|
},
|
|
open: open,
|
|
close: close,
|
|
// The flat list the command palette offers alongside its own
|
|
// actions -- id and label only; it draws its own rows.
|
|
entries: function() {
|
|
if (!sections.length) build();
|
|
const out = [];
|
|
sections.forEach((section) => section.rows.forEach((row) => {
|
|
out.push({ id: row.id, label: row.name, group: section.title });
|
|
}));
|
|
return out;
|
|
},
|
|
choose: function(id) {
|
|
const session = App.storage.getSession();
|
|
const data = activeServerData();
|
|
const nextChannel = data ? App.session.resolveChannelById(data, id) : null;
|
|
if (!session || !nextChannel) return;
|
|
const serverPrefs = App.storage.getPreferences()[session.server] || {};
|
|
const savedOptions = serverPrefs.optionsByChannel ?
|
|
serverPrefs.optionsByChannel[nextChannel.id] : null;
|
|
const nextSession = {
|
|
server: session.server,
|
|
channel: nextChannel,
|
|
options: savedOptions ?
|
|
App.session.hydrateOptions(nextChannel, savedOptions) :
|
|
App.session.buildDefaultOptions(nextChannel)
|
|
};
|
|
App.storage.setSession(nextSession);
|
|
App.session.savePreference(nextSession);
|
|
close();
|
|
App.ui.renderMenu();
|
|
App.videos.resetAndReload();
|
|
}
|
|
};
|
|
})();
|
|
|
|
// Settings + menu rendering.
|
|
App.ui.renderMenu = function() {
|
|
const session = App.storage.getSession();
|
|
const serverEntries = App.storage.getServerEntries();
|
|
const sourceSelect = document.getElementById('source-select');
|
|
const filtersContainer = document.getElementById('filters-container');
|
|
const sourcesList = document.getElementById('sources-list');
|
|
const addSourceBtn = document.getElementById('add-source-btn');
|
|
const sourceInput = document.getElementById('source-input');
|
|
const reloadChannelBtn = document.getElementById('reload-channel-btn');
|
|
const favoritesToggle = document.getElementById('favorites-toggle');
|
|
|
|
if (!sourceSelect || !filtersContainer) return;
|
|
|
|
sourceSelect.innerHTML = "";
|
|
serverEntries.forEach((entry) => {
|
|
const option = document.createElement('option');
|
|
option.value = entry.url;
|
|
option.textContent = entry.url;
|
|
sourceSelect.appendChild(option);
|
|
});
|
|
|
|
if (session && session.server) {
|
|
sourceSelect.value = session.server;
|
|
}
|
|
|
|
sourceSelect.onchange = () => {
|
|
const selectedServerUrl = sourceSelect.value;
|
|
const selectedServer = serverEntries.find((entry) => entry.url === selectedServerUrl);
|
|
const selectedServerData = selectedServer && selectedServer.data ? selectedServer.data : null;
|
|
const channels = selectedServerData && selectedServerData.channels ? selectedServerData.channels : [];
|
|
const prefs = App.storage.getPreferences();
|
|
const serverPrefs = prefs[selectedServerUrl] || {};
|
|
const preferredChannel = selectedServerData ?
|
|
App.session.resolveChannelById(selectedServerData, serverPrefs.channelId) :
|
|
null;
|
|
const nextChannel = preferredChannel || (channels.length > 0 ? channels[0] : null);
|
|
const savedOptions = nextChannel && serverPrefs.optionsByChannel ?
|
|
serverPrefs.optionsByChannel[nextChannel.id] :
|
|
null;
|
|
const nextSession = {
|
|
server: selectedServerUrl,
|
|
channel: nextChannel,
|
|
options: nextChannel ? (savedOptions ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {}
|
|
};
|
|
App.storage.setSession(nextSession);
|
|
App.session.savePreference(nextSession);
|
|
App.ui.renderMenu();
|
|
App.videos.resetAndReload();
|
|
};
|
|
|
|
App.ui.channels.render();
|
|
|
|
App.ui.renderFilters(filtersContainer, session);
|
|
|
|
const themeSelect = document.getElementById('theme-select');
|
|
if (themeSelect) {
|
|
themeSelect.onchange = () => {
|
|
const nextTheme = themeSelect.value === 'light' ? 'light' : 'dark';
|
|
localStorage.setItem('theme', nextTheme);
|
|
App.ui.applyTheme();
|
|
};
|
|
}
|
|
|
|
const qualitySelect = document.getElementById('quality-select');
|
|
if (qualitySelect) {
|
|
qualitySelect.onchange = () => {
|
|
App.storage.setPreferredQuality(qualitySelect.value);
|
|
};
|
|
}
|
|
|
|
const densitySelect = document.getElementById('density-select');
|
|
if (densitySelect) {
|
|
densitySelect.value = App.storage.getDensity();
|
|
densitySelect.onchange = () => {
|
|
App.storage.setDensity(densitySelect.value);
|
|
App.ui.applyDensity();
|
|
if (App.virtualGrid && typeof App.virtualGrid.relayout === 'function') {
|
|
App.virtualGrid.relayout();
|
|
}
|
|
};
|
|
}
|
|
|
|
const cardSizeRange = document.getElementById('card-size-range');
|
|
if (cardSizeRange) {
|
|
cardSizeRange.value = App.storage.getCardScale();
|
|
cardSizeRange.oninput = () => {
|
|
App.storage.setCardScale(cardSizeRange.value);
|
|
App.ui.applyCardScale();
|
|
};
|
|
}
|
|
|
|
const textSizeRange = document.getElementById('text-size-range');
|
|
if (textSizeRange) {
|
|
textSizeRange.value = App.storage.getFontScale();
|
|
textSizeRange.oninput = () => {
|
|
App.storage.setFontScale(textSizeRange.value);
|
|
App.ui.applyFontScale();
|
|
};
|
|
}
|
|
|
|
const feedEndSelect = document.getElementById('feed-end-select');
|
|
if (feedEndSelect) {
|
|
feedEndSelect.value = App.storage.getFeedEndBehavior();
|
|
feedEndSelect.onchange = () => {
|
|
App.storage.setFeedEndBehavior(feedEndSelect.value);
|
|
if (App.feed && typeof App.feed.applyEndBehavior === 'function') {
|
|
App.feed.applyEndBehavior();
|
|
}
|
|
};
|
|
}
|
|
|
|
if (favoritesToggle) {
|
|
favoritesToggle.checked = App.favorites.isVisible();
|
|
favoritesToggle.onchange = () => {
|
|
App.favorites.setVisible(favoritesToggle.checked);
|
|
App.favorites.renderBar();
|
|
};
|
|
}
|
|
|
|
if (sourcesList) {
|
|
sourcesList.innerHTML = "";
|
|
serverEntries.forEach((entry) => {
|
|
const row = document.createElement('div');
|
|
row.className = 'source-item';
|
|
|
|
const text = document.createElement('span');
|
|
text.textContent = entry.url;
|
|
|
|
const removeBtn = document.createElement('button');
|
|
removeBtn.type = 'button';
|
|
removeBtn.textContent = 'Remove';
|
|
removeBtn.onclick = async () => {
|
|
const config = App.storage.getConfig();
|
|
config.servers = (config.servers || []).filter((serverObj) => {
|
|
const key = Object.keys(serverObj)[0];
|
|
return key !== entry.url;
|
|
});
|
|
App.storage.setConfig(config);
|
|
const prefs = App.storage.getPreferences();
|
|
if (prefs[entry.url]) {
|
|
delete prefs[entry.url];
|
|
App.storage.setPreferences(prefs);
|
|
}
|
|
|
|
const remaining = App.storage.getServerEntries();
|
|
if (remaining.length === 0) {
|
|
localStorage.removeItem('session');
|
|
} else {
|
|
const nextServerUrl = remaining[0].url;
|
|
const nextServer = remaining[0];
|
|
const serverPrefs = prefs[nextServerUrl] || {};
|
|
const nextServerData = nextServer.data || null;
|
|
const channels = nextServerData && nextServerData.channels ? nextServerData.channels : [];
|
|
const nextChannel = (nextServerData && App.session.resolveChannelById(nextServerData, serverPrefs.channelId)) || channels[0] || null;
|
|
const savedOptions = nextChannel && serverPrefs.optionsByChannel ? serverPrefs.optionsByChannel[nextChannel.id] : null;
|
|
const nextSession = {
|
|
server: nextServerUrl,
|
|
channel: nextChannel,
|
|
options: nextChannel ? (savedOptions ? App.session.hydrateOptions(nextChannel, savedOptions) : App.session.buildDefaultOptions(nextChannel)) : {}
|
|
};
|
|
App.storage.setSession(nextSession);
|
|
App.session.savePreference(nextSession);
|
|
}
|
|
|
|
await App.storage.initializeServerStatus();
|
|
App.videos.resetAndReload();
|
|
App.ui.renderMenu();
|
|
};
|
|
|
|
row.appendChild(text);
|
|
row.appendChild(removeBtn);
|
|
sourcesList.appendChild(row);
|
|
});
|
|
}
|
|
|
|
if (addSourceBtn && sourceInput) {
|
|
addSourceBtn.onclick = async () => {
|
|
const raw = sourceInput.value.trim();
|
|
if (!raw) return;
|
|
const normalized = raw.endsWith('/') ? raw.slice(0, -1) : raw;
|
|
|
|
const config = App.storage.getConfig();
|
|
const exists = (config.servers || []).some((serverObj) => Object.keys(serverObj)[0] === normalized);
|
|
if (!exists) {
|
|
config.servers = config.servers || [];
|
|
config.servers.push({
|
|
[normalized]: {}
|
|
});
|
|
App.storage.setConfig(config);
|
|
sourceInput.value = '';
|
|
await App.storage.initializeServerStatus();
|
|
|
|
const session = App.storage.getSession();
|
|
if (!session || session.server !== normalized) {
|
|
const entries = App.storage.getServerEntries();
|
|
const addedEntry = entries.find((entry) => entry.url === normalized);
|
|
const nextChannel = addedEntry && addedEntry.data && addedEntry.data.channels ?
|
|
addedEntry.data.channels[0] :
|
|
null;
|
|
const nextSession = {
|
|
server: normalized,
|
|
channel: nextChannel,
|
|
options: nextChannel ? App.session.buildDefaultOptions(nextChannel) : {}
|
|
};
|
|
App.storage.setSession(nextSession);
|
|
App.session.savePreference(nextSession);
|
|
}
|
|
App.ui.renderMenu();
|
|
App.videos.resetAndReload();
|
|
}
|
|
};
|
|
}
|
|
|
|
if (reloadChannelBtn) {
|
|
reloadChannelBtn.onclick = () => {
|
|
// Refresh means "give me the current everything": the videos
|
|
// below, and the app itself. The version check runs in the
|
|
// background and only acts if the deployed assets actually
|
|
// differ from what this tab is running.
|
|
if (App.version && typeof App.version.checkNow === 'function') {
|
|
App.version.checkNow();
|
|
}
|
|
App.videos.resetAndReload();
|
|
};
|
|
}
|
|
|
|
// Source, channel and filters are all part of what a saved search is,
|
|
// so whenever the menu redraws, so does the bookmark.
|
|
if (App.ui.savedSearches) App.ui.savedSearches.sync();
|
|
};
|
|
|
|
App.ui.renderFilters = function(container, session) {
|
|
container.innerHTML = "";
|
|
|
|
if (!session || !session.channel || !Array.isArray(session.channel.options)) {
|
|
const empty = document.createElement('div');
|
|
empty.className = 'filters-empty';
|
|
empty.textContent = session && session.channel && session.channel.isGroup ?
|
|
'No filters available when browsing a whole channel group.' :
|
|
'No filters available for this channel.';
|
|
container.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
session.channel.options.forEach((optionGroup) => {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'setting-item';
|
|
|
|
const labelRow = document.createElement('div');
|
|
labelRow.className = 'setting-label-row';
|
|
|
|
const label = document.createElement('label');
|
|
label.textContent = optionGroup.title || optionGroup.id;
|
|
labelRow.appendChild(label);
|
|
const options = optionGroup.options || [];
|
|
const currentSelection = session.options ? session.options[optionGroup.id] : null;
|
|
|
|
if (optionGroup.multiSelect) {
|
|
const actionBtn = document.createElement('button');
|
|
actionBtn.type = 'button';
|
|
actionBtn.className = 'btn-link';
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'multi-select';
|
|
|
|
const selectedIds = new Set(
|
|
Array.isArray(currentSelection)
|
|
? currentSelection.map((item) => item.id)
|
|
: []
|
|
);
|
|
|
|
const updateActionLabel = () => {
|
|
const allChecked = options.length > 0 &&
|
|
Array.from(list.querySelectorAll('input[type="checkbox"]'))
|
|
.every((cb) => cb.checked);
|
|
actionBtn.textContent = allChecked ? 'Deselect all' : 'Select all';
|
|
actionBtn.disabled = options.length === 0;
|
|
};
|
|
|
|
options.forEach((opt) => {
|
|
const item = document.createElement('label');
|
|
item.className = 'multi-select-item';
|
|
|
|
const checkbox = document.createElement('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.value = opt.id;
|
|
checkbox.checked = selectedIds.has(opt.id);
|
|
|
|
const text = document.createElement('span');
|
|
text.textContent = opt.title || opt.id;
|
|
|
|
checkbox.onchange = () => {
|
|
const nextSession = App.storage.getSession();
|
|
if (!nextSession || !nextSession.channel) return;
|
|
const selected = [];
|
|
list.querySelectorAll('input[type="checkbox"]').forEach((cb) => {
|
|
if (cb.checked) {
|
|
const found = options.find((item) => item.id === cb.value);
|
|
if (found) selected.push(found);
|
|
}
|
|
});
|
|
nextSession.options[optionGroup.id] = selected;
|
|
App.storage.setSession(nextSession);
|
|
App.session.savePreference(nextSession);
|
|
App.videos.resetAndReload();
|
|
updateActionLabel();
|
|
};
|
|
|
|
item.appendChild(checkbox);
|
|
item.appendChild(text);
|
|
list.appendChild(item);
|
|
});
|
|
|
|
updateActionLabel();
|
|
|
|
actionBtn.onclick = () => {
|
|
const checkboxes = Array.from(list.querySelectorAll('input[type="checkbox"]'));
|
|
const allChecked = checkboxes.length > 0 && checkboxes.every((cb) => cb.checked);
|
|
checkboxes.forEach((cb) => {
|
|
cb.checked = !allChecked;
|
|
});
|
|
|
|
const nextSession = App.storage.getSession();
|
|
if (!nextSession || !nextSession.channel) return;
|
|
const selected = [];
|
|
if (!allChecked) {
|
|
options.forEach((opt) => selected.push(opt));
|
|
}
|
|
nextSession.options[optionGroup.id] = selected;
|
|
App.storage.setSession(nextSession);
|
|
App.session.savePreference(nextSession);
|
|
App.videos.resetAndReload();
|
|
updateActionLabel();
|
|
};
|
|
|
|
labelRow.appendChild(actionBtn);
|
|
wrapper.appendChild(labelRow);
|
|
wrapper.appendChild(list);
|
|
container.appendChild(wrapper);
|
|
return;
|
|
}
|
|
|
|
const select = document.createElement('select');
|
|
options.forEach((opt) => {
|
|
const option = document.createElement('option');
|
|
option.value = opt.id;
|
|
option.textContent = opt.title || opt.id;
|
|
select.appendChild(option);
|
|
});
|
|
|
|
if (currentSelection && currentSelection.id) {
|
|
select.value = currentSelection.id;
|
|
}
|
|
|
|
select.onchange = () => {
|
|
const nextSession = App.storage.getSession();
|
|
if (!nextSession || !nextSession.channel) return;
|
|
const selected = options.find((item) => item.id === select.value);
|
|
if (selected) {
|
|
nextSession.options[optionGroup.id] = selected;
|
|
}
|
|
|
|
App.storage.setSession(nextSession);
|
|
App.session.savePreference(nextSession);
|
|
App.videos.resetAndReload();
|
|
};
|
|
|
|
wrapper.appendChild(labelRow);
|
|
wrapper.appendChild(select);
|
|
container.appendChild(wrapper);
|
|
});
|
|
};
|
|
|
|
// Expose inline handlers + keyboard shortcuts.
|
|
// Settings -> Hot Tub Backup: pick an exported database and merge the
|
|
// favorites out of it. Bound once (unlike the controls in renderMenu, which
|
|
// are re-assigned on every render) because a file input mid-read must not
|
|
// have its handler swapped underneath it.
|
|
App.ui.bindBackupImport = function() {
|
|
const button = document.getElementById('import-favorites-btn');
|
|
const input = document.getElementById('import-favorites-file');
|
|
const status = document.getElementById('import-favorites-status');
|
|
if (!button || !input) return;
|
|
|
|
const say = (message) => { if (status) status.textContent = message; };
|
|
|
|
button.addEventListener('click', () => {
|
|
// Cleared first so picking the same file twice still fires change.
|
|
input.value = '';
|
|
input.click();
|
|
});
|
|
|
|
input.addEventListener('change', () => {
|
|
const file = input.files && input.files[0];
|
|
if (!file) return;
|
|
button.disabled = true;
|
|
say('Reading backup…');
|
|
App.hottubBackup.importFile(file).then((result) => {
|
|
if (!result.found) {
|
|
say('No favorites found in that backup.');
|
|
} else if (!result.added) {
|
|
say(`Nothing new: all ${result.found} favorites in that backup are already saved.`);
|
|
} else {
|
|
const plural = result.added === 1 ? 'favorite' : 'favorites';
|
|
const already = result.skipped ? ` ${result.skipped} were already saved.` : '';
|
|
say(`Imported ${result.added} ${plural}.${already}`);
|
|
}
|
|
}).catch((err) => {
|
|
say('Could not read that file.');
|
|
App.ui.showError((err && err.message) || 'Could not read that backup.');
|
|
}).then(() => {
|
|
button.disabled = false;
|
|
});
|
|
});
|
|
};
|
|
|
|
// Favorites bar header: the sort order (which applies to the bar and the
|
|
// favorites grid alike) and the toggle into that grid.
|
|
App.ui.bindFavoritesControls = function() {
|
|
const select = document.getElementById('favorites-sort');
|
|
const button = document.getElementById('favorites-browse-btn');
|
|
|
|
if (select && !select.options.length) {
|
|
App.favorites.SORTS.forEach((sort) => {
|
|
const option = document.createElement('option');
|
|
option.value = sort.id;
|
|
option.textContent = sort.label;
|
|
select.appendChild(option);
|
|
});
|
|
select.value = App.favorites.getSort();
|
|
select.addEventListener('change', () => App.favoritesView.applySort(select.value));
|
|
}
|
|
|
|
if (button) {
|
|
button.addEventListener('click', () => App.favoritesView.toggle());
|
|
}
|
|
App.favoritesView.syncControls();
|
|
};
|
|
|
|
App.ui.bindGlobalHandlers = function() {
|
|
App.ui.bindBackupImport();
|
|
App.ui.bindFavoritesControls();
|
|
|
|
window.toggleDrawer = App.ui.toggleDrawer;
|
|
window.closeDrawers = App.ui.closeDrawers;
|
|
window.handleSearch = App.videos.handleSearch;
|
|
|
|
const modeToggleBtn = document.getElementById('mode-toggle-btn');
|
|
if (modeToggleBtn) {
|
|
modeToggleBtn.onclick = () => {
|
|
App.feed.toggle();
|
|
};
|
|
}
|
|
|
|
const feedMuteBtn = document.getElementById('feed-mute-btn');
|
|
if (feedMuteBtn) {
|
|
feedMuteBtn.onclick = () => {
|
|
App.feed.toggleMute();
|
|
};
|
|
}
|
|
|
|
const searchInput = document.getElementById('search-input');
|
|
const clearSearchBtn = document.getElementById('search-clear-btn');
|
|
if (searchInput && clearSearchBtn) {
|
|
let searchDebounce = null;
|
|
const SEARCH_DEBOUNCE_MS = 300;
|
|
|
|
const updateClearVisibility = () => {
|
|
const hasValue = searchInput.value.trim().length > 0;
|
|
clearSearchBtn.classList.toggle('is-visible', hasValue);
|
|
clearSearchBtn.disabled = !hasValue;
|
|
};
|
|
|
|
clearSearchBtn.addEventListener('click', (event) => {
|
|
event.preventDefault();
|
|
if (!searchInput.value) return;
|
|
if (searchDebounce) clearTimeout(searchDebounce);
|
|
searchInput.value = '';
|
|
updateClearVisibility();
|
|
App.videos.handleSearch('');
|
|
searchInput.focus();
|
|
});
|
|
|
|
// Update the clear button immediately for snappy feedback, but
|
|
// debounce the actual reload so typing doesn't wipe the grid and
|
|
// fire a backend request on every keystroke.
|
|
App.ui.searchHistory.bind();
|
|
App.ui.savedSearches.bind();
|
|
searchInput.addEventListener('input', updateClearVisibility);
|
|
// The bookmark is about this search, so it follows the words as
|
|
// they are typed, not just the channel and filters.
|
|
searchInput.addEventListener('input', () => App.ui.savedSearches.sync());
|
|
searchInput.addEventListener('input', () => {
|
|
if (searchDebounce) clearTimeout(searchDebounce);
|
|
searchDebounce = setTimeout(() => {
|
|
searchDebounce = null;
|
|
App.videos.handleSearch(searchInput.value);
|
|
}, SEARCH_DEBOUNCE_MS);
|
|
});
|
|
updateClearVisibility();
|
|
}
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape') {
|
|
App.ui.closeDrawers();
|
|
App.ui.closeInfo();
|
|
App.videos.closeAllMenus();
|
|
if (App.feed.isOpen()) {
|
|
App.feed.close();
|
|
}
|
|
}
|
|
});
|
|
|
|
document.addEventListener('click', () => {
|
|
App.videos.closeAllMenus();
|
|
});
|
|
|
|
const infoModal = document.getElementById('info-modal');
|
|
if (infoModal) {
|
|
infoModal.addEventListener('click', (event) => {
|
|
if (event.target === infoModal) {
|
|
App.ui.closeInfo();
|
|
}
|
|
});
|
|
}
|
|
|
|
const infoClose = document.getElementById('info-close');
|
|
if (infoClose) {
|
|
infoClose.addEventListener('click', () => {
|
|
App.ui.closeInfo();
|
|
});
|
|
}
|
|
|
|
const infoCopy = document.getElementById('info-copy');
|
|
if (infoCopy) {
|
|
infoCopy.addEventListener('click', () => {
|
|
App.ui.copyInfoJson();
|
|
});
|
|
}
|
|
};
|
|
})();
|