Ein neuer Abschnitt (docs/sources.md, static/js/sources.js) für Podcasts, YouTube-Kanäle, Hörbücher, Nachrichten und Blogs: - ein Feld zum Hinzufügen: Adresse (Feed, Seite, YouTube) oder Name eines Podcasts; die Art ist wählbar (Podcast, Hörbuch, Nachrichten, Blog); /feeds/discover/ erkennt dafür Podcasts (Folgen mit Ton oder Video); - eine Liste aller Abos mit Filtern (auch „Probleme“) und Suche; - je Abo: Inhalt öffnen, Einstellungen (die vorhandenen Editoren), Art ändern, abbestellen; OPML-Import und -Export. In der Seitenleiste unten, auf dem Telefon im App-Menü; ⚙ in den Feeds führt hierher. Abschnitte, die beim Laden schon gezeigt werden, füllen sich jetzt auch (nav.js zeigt sie nach dem Laden aller Skripte noch einmal). Podcast-UI aus diora-android 50c6f9c. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
481 lines
23 KiB
JavaScript
481 lines
23 KiB
JavaScript
/*
|
|
* Abos & Quellen (docs/sources.md): every subscription in one place — podcasts, YouTube
|
|
* channels, audiobooks, news sources, blogs. One field adds any of them (an address or
|
|
* search words), one list shows them all, a source's actions open its content, its
|
|
* settings (the existing editors, through their hooks), change its kind or unsubscribe.
|
|
* Runs in the web page, the desktop app and the Android apps (diora Reader: news and
|
|
* blogs only, there are no podcasts). Chrome-61-safe: no ?. / ??.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
const tab = document.getElementById('tab-sources');
|
|
if (!tab) return;
|
|
|
|
const st = {
|
|
filter: 'all',
|
|
q: '',
|
|
feeds: null, // /feeds/state/ sources
|
|
adding: null, // {loading, error, found: [candidate…]}
|
|
open: null, // key of the source whose actions are shown
|
|
busy: false,
|
|
};
|
|
try {
|
|
const f = localStorage.getItem('diora_sources_filter');
|
|
if (f) st.filter = f;
|
|
} catch (e) {}
|
|
|
|
const FILTERS = [
|
|
['all', 'Alle'], ['podcast', 'Podcasts'], ['youtube', 'YouTube'], ['audiobook', 'Hörbücher'],
|
|
['news', 'Nachrichten'], ['blog', 'Blogs'], ['problem', 'Probleme'],
|
|
];
|
|
const KIND_LABEL = {podcast: 'Podcast', youtube: 'YouTube', audiobook: 'Hörbuch', news: 'Nachrichten', blog: 'Blog'};
|
|
|
|
// ---- helpers -------------------------------------------------------------------------
|
|
|
|
function esc(s) {
|
|
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
|
|
({'&': '&', '<': '<', '>': '>', '"': '"', "'": '''}[c]));
|
|
}
|
|
|
|
function host() {
|
|
const n = window.DioraNative;
|
|
if (n && typeof n.podcastState === 'function') return n;
|
|
return window.DioraPodcastHost || null;
|
|
}
|
|
|
|
function podcastFeeds() {
|
|
const h = host();
|
|
if (!h) return [];
|
|
try { return (JSON.parse(h.podcastState()) || {}).feeds || []; } catch (e) { return []; }
|
|
}
|
|
|
|
async function api(path, body) {
|
|
const opts = {credentials: 'same-origin', cache: 'no-store'};
|
|
if (body !== undefined) {
|
|
opts.method = 'POST';
|
|
opts.headers = {'Content-Type': 'application/json'};
|
|
opts.body = JSON.stringify(body);
|
|
}
|
|
const r = await fetch(path, opts);
|
|
const d = await r.json().catch(() => null);
|
|
if (!r.ok || !d) throw new Error((d && d.error) || ('HTTP ' + r.status));
|
|
return d;
|
|
}
|
|
|
|
function toast(text) {
|
|
let t = document.getElementById('src-toast');
|
|
if (!t) {
|
|
t = document.createElement('div');
|
|
t.id = 'src-toast';
|
|
document.body.appendChild(t);
|
|
}
|
|
t.textContent = text;
|
|
t.classList.add('src-show');
|
|
clearTimeout(toast.timer);
|
|
toast.timer = setTimeout(() => t.classList.remove('src-show'), 2800);
|
|
}
|
|
|
|
const isYouTubeFeed = url => /^https?:\/\/(www\.)?youtube\.com\/feeds\/videos\.xml/.test(url || '');
|
|
const isYouTubeAddress = s => /^(https?:\/\/)?((www|m|music)\.)?(youtube\.com|youtu\.be)\//i.test(s.trim());
|
|
const looksLikeAddress = s => !/\s/.test(s.trim()) && /\.[a-z]{2,}/i.test(s);
|
|
|
|
function go(id) {
|
|
if (window.DioraNav) window.DioraNav.go(id);
|
|
else if (typeof showTab === 'function') showTab(id);
|
|
}
|
|
|
|
// ---- all sources as one list -----------------------------------------------------------
|
|
|
|
function rows() {
|
|
const out = [];
|
|
podcastFeeds().forEach(f => {
|
|
const type = f.kind === 'audiobook' ? 'audiobook' : isYouTubeFeed(f.rss_url) ? 'youtube' : 'podcast';
|
|
out.push({
|
|
key: 'p' + f.id, type, id: f.id, title: f.title || f.rss_url, sub: f.author || '',
|
|
art: f.artwork_url || '', url: f.rss_url,
|
|
meta: (f.episode_count ? f.episode_count + ' Folgen' : '') + (f.new_count ? ' · ' + f.new_count + ' neu' : ''),
|
|
problem: (f.error_count || 0) >= 3 ? (f.last_error || 'Abruf schlägt fehl') : '',
|
|
});
|
|
});
|
|
(st.feeds || []).forEach(s => {
|
|
out.push({
|
|
key: 'f' + s.id, type: s.kind === 'news' ? 'news' : 'blog', id: s.id, title: s.title || s.url,
|
|
sub: s.kind === 'news' ? (s.category || 'Allgemein') : (s.folders || []).join(', '),
|
|
art: s.icon_url || '', url: s.url,
|
|
meta: s.unread ? s.unread + ' ungelesen' : '',
|
|
problem: (s.error_count || 0) >= 3 ? (s.last_error || 'Abruf schlägt fehl') : '',
|
|
});
|
|
});
|
|
return out.sort((a, b) => a.title.localeCompare(b.title, 'de', {sensitivity: 'base'}));
|
|
}
|
|
|
|
function shown(list) {
|
|
const q = st.q.trim().toLowerCase();
|
|
return list.filter(r =>
|
|
(st.filter === 'all' || (st.filter === 'problem' ? !!r.problem : r.type === st.filter)) &&
|
|
(!q || (r.title + ' ' + r.sub + ' ' + r.url).toLowerCase().indexOf(q) >= 0));
|
|
}
|
|
|
|
// ---- drawing ---------------------------------------------------------------------------
|
|
|
|
function render() {
|
|
const all = rows();
|
|
const list = shown(all);
|
|
const count = id => id === 'all' ? all.length
|
|
: id === 'problem' ? all.filter(r => r.problem).length : all.filter(r => r.type === id).length;
|
|
let h = '<div class="src">';
|
|
h += '<h2 class="src-h1">Abos & Quellen</h2>';
|
|
h += addHtml();
|
|
h += '<div class="src-chips">' + FILTERS.filter(f => f[0] === 'all' || f[0] === st.filter || count(f[0]))
|
|
.map(f => '<button type="button" class="src-chip' + (st.filter === f[0] ? ' on' : '') + '" data-filter="' + f[0] + '">' +
|
|
esc(f[1]) + ' <span class="src-count">' + count(f[0]) + '</span></button>').join('') + '</div>';
|
|
h += '<input type="search" class="src-q" placeholder="In ' + all.length + ' Abos suchen …" value="' + esc(st.q) + '">';
|
|
if (st.feeds === null && !all.length) h += '<p class="src-note">Wird geladen …</p>';
|
|
else if (!list.length) h += '<p class="src-note">' + (all.length ? 'Nichts gefunden.' : 'Noch keine Abos. Oben eine Adresse oder einen Namen eingeben.') + '</p>';
|
|
h += '<div class="src-list">' + list.map(rowHtml).join('') + '</div>';
|
|
h += ioHtml();
|
|
h += '</div>';
|
|
const q = tab.querySelector('.src-q');
|
|
const focused = q && document.activeElement === q;
|
|
tab.innerHTML = h;
|
|
if (focused) {
|
|
const n = tab.querySelector('.src-q');
|
|
n.focus();
|
|
n.setSelectionRange(n.value.length, n.value.length);
|
|
}
|
|
}
|
|
|
|
function addHtml() {
|
|
const a = st.adding;
|
|
let h = '<form class="src-add" data-form="add"><input class="src-add-q" name="q" autocomplete="off" ' +
|
|
'placeholder="Adresse (Seite, Feed, YouTube) oder Name eines Podcasts" value="' + esc(a && a.query || '') + '">' +
|
|
'<button class="src-btn src-primary" type="submit"' + (a && a.loading ? ' disabled' : '') + '>' +
|
|
(a && a.loading ? '…' : 'Suchen') + '</button></form>';
|
|
if (!a) return h;
|
|
if (a.error) h += '<p class="src-warn">' + esc(a.error) + '</p>';
|
|
if (a.found && a.found.length) {
|
|
h += '<div class="src-found">' + a.found.map((c, n) =>
|
|
'<div class="src-cand">' + art(c.art, c.title, 'src-art') +
|
|
'<div class="src-text"><div class="src-title">' + esc(c.title) + '</div>' +
|
|
'<div class="src-meta">' + esc(c.sub || c.url) + '</div>' +
|
|
(c.samples && c.samples.length ? '<div class="src-meta">' + c.samples.slice(0, 2).map(esc).join(' · ') + '</div>' : '') +
|
|
(c.subscribed ? '<div class="src-meta">✓ schon abonniert</div>' :
|
|
'<div class="src-kinds">' + c.kinds.map(k =>
|
|
'<button type="button" class="src-btn' + (k === c.kind ? ' src-primary' : '') + '" data-sub="' + n + '" data-kind="' + k + '">' +
|
|
(k === c.kind ? '+ ' : '') + esc(KIND_LABEL[k]) + '</button>').join('') + '</div>') +
|
|
'</div></div>').join('') + '</div>';
|
|
}
|
|
return h;
|
|
}
|
|
|
|
function art(url, title, cls) {
|
|
const u = String(url || '').replace(/^http:/, 'https:');
|
|
return u ? '<img class="' + cls + '" src="' + esc(u) + '" alt="" loading="lazy">'
|
|
: '<span class="' + cls + ' src-noart">' + esc((title || '?').trim().charAt(0).toUpperCase()) + '</span>';
|
|
}
|
|
|
|
function rowHtml(r) {
|
|
const open = st.open === r.key;
|
|
let h = '<div class="src-row' + (open ? ' src-open' : '') + '"><button type="button" class="src-main" data-row="' + r.key + '">' +
|
|
art(r.art, r.title, 'src-art') + '<span class="src-text"><span class="src-title">' + esc(r.title) + '</span>' +
|
|
'<span class="src-meta"><span class="src-kind src-kind-' + r.type + '">' + esc(KIND_LABEL[r.type]) + '</span>' +
|
|
(r.sub ? ' ' + esc(r.sub) : '') + (r.meta ? ' · ' + esc(r.meta) : '') + '</span>' +
|
|
(r.problem ? '<span class="src-warn">⚠ ' + esc(r.problem) + '</span>' : '') + '</span></button>';
|
|
if (open) h += actionsHtml(r);
|
|
return h + '</div>';
|
|
}
|
|
|
|
function actionsHtml(r) {
|
|
const a = (act, label, cls) => '<button type="button" class="src-btn' + (cls ? ' ' + cls : '') + '" data-act="' + act + '" data-key="' + r.key + '">' + label + '</button>';
|
|
const list = [];
|
|
if (r.type === 'audiobook') list.push(a('open', '▶ Hörbuch öffnen'));
|
|
else if (r.type === 'news' || r.type === 'blog') list.push(a('open', r.type === 'news' ? 'Nachrichten lesen' : 'Blog lesen'));
|
|
else list.push(a('open', 'Folgen ansehen'));
|
|
list.push(a('settings', 'Einstellungen …'));
|
|
if (r.type === 'podcast' || r.type === 'youtube') list.push(a('to-audiobook', 'Als Hörbuch führen'));
|
|
if (r.type === 'audiobook') list.push(a('to-podcast', 'Als Podcast führen'));
|
|
if (r.type === 'news') list.push(a('to-blog', 'Als Blog führen'));
|
|
if (r.type === 'blog') list.push(a('to-news', 'Als Nachrichten führen'));
|
|
list.push(a('remove', 'Abbestellen', 'src-danger'));
|
|
return '<div class="src-actions"><div class="src-meta src-url">' + esc(r.url) + '</div>' + list.join('') + '</div>';
|
|
}
|
|
|
|
function ioHtml() {
|
|
const h = host();
|
|
const web = !window.DioraNative;
|
|
return '<details class="src-io"><summary>Import & Export</summary>' +
|
|
(h ? '<label class="src-btn src-file">Podcasts aus OPML …<input type="file" accept=".opml,.xml,text/xml,text/x-opml" data-io="podcasts" hidden></label>' : '') +
|
|
(h && web ? '<a class="src-btn" href="/podcasts/feeds/export.opml">Podcasts als OPML</a>' : '') +
|
|
'<button type="button" class="src-btn" data-io="feeds-opml">Feeds aus OPML …</button>' +
|
|
(web ? '<a class="src-btn" href="/feeds/export.opml">Feeds als OPML</a>' : '') +
|
|
'<p class="src-note">Feeds aus einer OPML-Datei werden in der Feed-Verwaltung übernommen (mit Vorschau).</p></details>';
|
|
}
|
|
|
|
// ---- adding --------------------------------------------------------------------------------
|
|
|
|
async function find(query) {
|
|
const q = query.trim();
|
|
if (!q) return;
|
|
st.adding = {query: q, loading: true};
|
|
render();
|
|
try {
|
|
let found = [];
|
|
const podcasts = !!host();
|
|
if (isYouTubeAddress(q) && podcasts) {
|
|
const d = await api('/podcasts/search/?q=' + encodeURIComponent(q));
|
|
found = (d.results || []).map(r => ({title: r.title, sub: 'YouTube', url: r.rss_url, art: r.artwork_url,
|
|
kind: 'podcast', kinds: ['podcast', 'audiobook'], subscribed: podcastFeeds().some(f => f.rss_url === r.rss_url)}));
|
|
if (!found.length) throw new Error('Dort ist kein YouTube-Kanal zu finden.');
|
|
} else if (looksLikeAddress(q)) {
|
|
const d = await api('/feeds/discover/', {url: q});
|
|
found = (d.feeds || []).map(f => {
|
|
const kind = f.suggested_kind === 'podcast' && !podcasts ? 'blog' : f.suggested_kind;
|
|
return {title: f.title, url: f.url, samples: f.samples, kind,
|
|
kinds: podcasts ? ['podcast', 'audiobook', 'news', 'blog'] : ['news', 'blog'],
|
|
subscribed: kind === 'podcast' || kind === 'audiobook' ? f.podcast_subscribed : f.subscribed};
|
|
});
|
|
} else if (podcasts) {
|
|
const d = await api('/podcasts/search/?q=' + encodeURIComponent(q));
|
|
found = (d.results || []).filter(r => r.rss_url).slice(0, 12).map(r => ({title: r.title, sub: r.author,
|
|
url: r.rss_url, art: r.artwork_url, kind: 'podcast', kinds: ['podcast', 'audiobook'],
|
|
subscribed: podcastFeeds().some(f => f.rss_url === r.rss_url)}));
|
|
if (!found.length) throw new Error('Kein Podcast dieses Namens gefunden.');
|
|
} else {
|
|
throw new Error('Bitte die Adresse einer Seite oder eines Feeds eingeben.');
|
|
}
|
|
st.adding = {query: q, found};
|
|
} catch (e) {
|
|
st.adding = {query: q, error: navigator.onLine ? (e.message || 'Hat nicht geklappt.') : 'Das braucht eine Verbindung.'};
|
|
}
|
|
render();
|
|
}
|
|
|
|
async function subscribe(c, kind) {
|
|
if (st.busy) return;
|
|
st.busy = true;
|
|
toast('Wird abonniert …');
|
|
try {
|
|
if (kind === 'podcast' || kind === 'audiobook') {
|
|
const d = await api('/podcasts/feeds/add/', {rss_url: c.url, title: c.title});
|
|
if (kind === 'audiobook') await api('/podcasts/feeds/' + d.feed_id + '/settings/', {kind: 'audiobook'});
|
|
refreshPodcasts();
|
|
toast('„' + (d.title || c.title) + '“ abonniert' + (kind === 'audiobook' ? ' (Hörbuch)' : ''));
|
|
} else {
|
|
await api('/feeds/sources/add/', {url: c.url, title: c.title, kind});
|
|
if (window.__dioraFeedsStale) window.__dioraFeedsStale();
|
|
toast('„' + c.title + '“ abonniert');
|
|
await loadFeeds();
|
|
}
|
|
c.subscribed = true;
|
|
} catch (e) {
|
|
toast(navigator.onLine ? 'Das ließ sich nicht abonnieren.' : 'Abonnieren braucht eine Verbindung.');
|
|
}
|
|
st.busy = false;
|
|
render();
|
|
}
|
|
|
|
function refreshPodcasts() {
|
|
const h = host();
|
|
// The server read the new feed already: the host only needs its state again.
|
|
if (h && typeof h.podcastRefresh === 'function') h.podcastRefresh(false);
|
|
// The host's state comes a moment later.
|
|
setTimeout(render, 1500);
|
|
setTimeout(render, 4000);
|
|
}
|
|
|
|
// ---- a source's actions -----------------------------------------------------------------------
|
|
|
|
async function act(name, r) {
|
|
const h = host();
|
|
if (name === 'open') {
|
|
if (r.type === 'audiobook') {
|
|
go('audiobooks');
|
|
if (window.__dioraAudiobookOpen) window.__dioraAudiobookOpen(r.id);
|
|
} else if (r.type === 'news' || r.type === 'blog') {
|
|
go(r.type === 'news' ? 'news' : 'blogs');
|
|
} else if (window.__dioraPodcastOpenFeed) {
|
|
window.__dioraPodcastOpenFeed(r.id);
|
|
} else {
|
|
go('podcasts');
|
|
}
|
|
return;
|
|
}
|
|
if (name === 'settings') {
|
|
if (r.type === 'news' || r.type === 'blog') {
|
|
if (window.__dioraFeedsEdit) window.__dioraFeedsEdit(r.id);
|
|
else go(r.type === 'news' ? 'news' : 'blogs');
|
|
} else if (window.__dioraPodcastFeedSettings) {
|
|
window.__dioraPodcastFeedSettings(r.id);
|
|
} else if (window.__dioraPodcastOpenFeed) {
|
|
window.__dioraPodcastOpenFeed(r.id);
|
|
} else {
|
|
go('podcasts');
|
|
}
|
|
return;
|
|
}
|
|
if (name === 'to-audiobook' || name === 'to-podcast') {
|
|
if (!h) return;
|
|
h.podcastFeedSettings(r.id, JSON.stringify({kind: name === 'to-audiobook' ? 'audiobook' : 'podcast'}));
|
|
toast(name === 'to-audiobook' ? '„' + r.title + '“ ist jetzt ein Hörbuch' : '„' + r.title + '“ ist wieder ein Podcast');
|
|
st.open = null;
|
|
render();
|
|
setTimeout(render, 600);
|
|
return;
|
|
}
|
|
if (name === 'to-blog' || name === 'to-news') {
|
|
try {
|
|
await api('/feeds/sources/' + r.id + '/', {kind: name === 'to-blog' ? 'blog' : 'news'});
|
|
if (window.__dioraFeedsStale) window.__dioraFeedsStale();
|
|
await loadFeeds();
|
|
} catch (e) { toast('Das braucht eine Verbindung.'); }
|
|
return;
|
|
}
|
|
if (name === 'remove') {
|
|
const ok = typeof customConfirm === 'function'
|
|
? await customConfirm('„' + r.title + '“ abbestellen?', {danger: true})
|
|
: window.confirm('„' + r.title + '“ abbestellen?');
|
|
if (!ok) return;
|
|
try {
|
|
if (r.type === 'news' || r.type === 'blog') {
|
|
await api('/feeds/sources/' + r.id + '/delete/', {});
|
|
if (window.__dioraFeedsStale) window.__dioraFeedsStale();
|
|
await loadFeeds();
|
|
} else {
|
|
await api('/podcasts/feeds/' + r.id + '/remove/', {});
|
|
if (h && typeof h.podcastFeedRemoved === 'function') h.podcastFeedRemoved();
|
|
setTimeout(render, 1500);
|
|
}
|
|
st.open = null;
|
|
toast('„' + r.title + '“ abbestellt');
|
|
} catch (e) { toast('Das braucht eine Verbindung.'); }
|
|
render();
|
|
}
|
|
}
|
|
|
|
async function importPodcasts(file) {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
toast('Wird übernommen …');
|
|
try {
|
|
const r = await fetch('/podcasts/feeds/import/', {method: 'POST', body: form, credentials: 'same-origin'});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error || r.status);
|
|
toast((d.added || 0) + ' Podcasts übernommen');
|
|
refreshPodcasts();
|
|
} catch (e) {
|
|
toast('Die Datei ließ sich nicht übernehmen.');
|
|
}
|
|
}
|
|
|
|
// ---- events ---------------------------------------------------------------------------------
|
|
|
|
tab.addEventListener('submit', e => {
|
|
const form = e.target.closest('[data-form="add"]');
|
|
if (!form) return;
|
|
e.preventDefault();
|
|
find(form.q.value);
|
|
});
|
|
tab.addEventListener('input', e => {
|
|
if (!e.target.classList.contains('src-q')) return;
|
|
st.q = e.target.value;
|
|
render();
|
|
});
|
|
tab.addEventListener('change', e => {
|
|
const io = e.target.dataset && e.target.dataset.io;
|
|
if (io === 'podcasts' && e.target.files && e.target.files[0]) importPodcasts(e.target.files[0]);
|
|
});
|
|
tab.addEventListener('click', e => {
|
|
const t = e.target.closest('button, label');
|
|
if (!t) return;
|
|
if (t.dataset.filter) {
|
|
st.filter = t.dataset.filter;
|
|
try { localStorage.setItem('diora_sources_filter', st.filter); } catch (err) {}
|
|
render();
|
|
} else if (t.dataset.row) {
|
|
st.open = st.open === t.dataset.row ? null : t.dataset.row;
|
|
render();
|
|
} else if (t.dataset.act) {
|
|
const r = rows().filter(x => x.key === t.dataset.key)[0];
|
|
if (r) act(t.dataset.act, r);
|
|
} else if (t.dataset.sub != null) {
|
|
const c = st.adding && st.adding.found && st.adding.found[Number(t.dataset.sub)];
|
|
if (c) subscribe(c, t.dataset.kind);
|
|
} else if (t.dataset.io === 'feeds-opml') {
|
|
if (window.__dioraFeedsEdit) window.__dioraFeedsEdit(null);
|
|
else go('news');
|
|
}
|
|
});
|
|
|
|
// ---- loading, showing --------------------------------------------------------------------------
|
|
|
|
async function loadFeeds() {
|
|
try {
|
|
const d = await api('/feeds/state/');
|
|
st.feeds = d.sources || [];
|
|
} catch (e) {
|
|
if (st.feeds === null) st.feeds = [];
|
|
}
|
|
render();
|
|
}
|
|
|
|
function show() {
|
|
// Another view may have chosen what to show (feeds.js: its own kind).
|
|
try {
|
|
const f = localStorage.getItem('diora_sources_filter');
|
|
if (f && FILTERS.some(x => x[0] === f)) st.filter = f;
|
|
} catch (e) {}
|
|
render();
|
|
loadFeeds();
|
|
}
|
|
|
|
const showTab = window.showTab;
|
|
if (typeof showTab === 'function') {
|
|
window.showTab = function (name) {
|
|
const r = showTab.apply(this, arguments);
|
|
if (name === 'sources') show();
|
|
return r;
|
|
};
|
|
}
|
|
if (tab.style.display !== 'none') show();
|
|
|
|
// ---- styles --------------------------------------------------------------------------------------
|
|
|
|
const style = document.createElement('style');
|
|
style.textContent = [
|
|
'#tab-sources .src { max-width: 760px; margin: 0 auto; padding-bottom: 24px; }',
|
|
'#tab-sources .src-h1 { font-size: 18px; margin: 4px 0 12px; }',
|
|
'#tab-sources .src-add { display: flex; gap: 8px; }',
|
|
'#tab-sources .src-add-q, #tab-sources .src-q { flex: 1; min-width: 0; box-sizing: border-box; padding: 10px 12px; border-radius: 6px; border: 1px solid var(--border, #2a2a2a); background: var(--bg, #000); color: inherit; font: inherit; }',
|
|
'#tab-sources .src-q { width: 100%; margin: 8px 0 4px; }',
|
|
'#tab-sources .src-btn { display: inline-flex; align-items: center; min-height: 38px; padding: 0 14px; border-radius: 19px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 14px; cursor: pointer; text-decoration: none; margin: 4px 6px 0 0; }',
|
|
'#tab-sources .src-primary { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
|
|
'#tab-sources .src-danger { color: var(--accent, #e63946); }',
|
|
'#tab-sources .src-btn[disabled] { opacity: .5; }',
|
|
'#tab-sources .src-found { margin: 10px 0 4px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px; background: var(--card, #0b0b0b); }',
|
|
'#tab-sources .src-cand { display: flex; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border, #2a2a2a); }',
|
|
'#tab-sources .src-cand:last-child { border-bottom: none; }',
|
|
'#tab-sources .src-chips { display: flex; flex-wrap: wrap; gap: 6px; margin: 16px 0 4px; }',
|
|
'#tab-sources .src-chip { min-height: 34px; padding: 0 12px; border-radius: 17px; border: 1px solid var(--border-strong, #444); background: none; color: inherit; font: inherit; font-size: 13px; cursor: pointer; }',
|
|
'#tab-sources .src-chip.on { background: var(--accent, #e63946); border-color: var(--accent, #e63946); color: var(--on-accent, #fff); }',
|
|
'#tab-sources .src-count { opacity: .7; font-size: 12px; }',
|
|
'#tab-sources .src-row { border-bottom: 1px solid var(--border, #2a2a2a); }',
|
|
'#tab-sources .src-main { display: flex; align-items: center; gap: 10px; width: 100%; padding: 9px 0; background: none; border: none; color: inherit; font: inherit; text-align: left; cursor: pointer; }',
|
|
'#tab-sources .src-main:hover { background: var(--hover, rgba(255,255,255,.04)); }',
|
|
'#tab-sources .src-art { width: 40px; height: 40px; flex: none; border-radius: 6px; object-fit: cover; background: var(--panel, #161616); }',
|
|
'#tab-sources .src-noart { display: flex; align-items: center; justify-content: center; color: var(--muted, #9a9a9a); }',
|
|
'#tab-sources .src-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }',
|
|
'#tab-sources .src-title { font-size: 14px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }',
|
|
'#tab-sources .src-meta, #tab-sources .src-note { color: var(--muted, #9a9a9a); font-size: 12px; overflow: hidden; text-overflow: ellipsis; }',
|
|
'#tab-sources .src-url { white-space: nowrap; margin-bottom: 4px; }',
|
|
'#tab-sources .src-warn { color: var(--accent, #e63946); font-size: 12px; }',
|
|
'#tab-sources .src-kind { display: inline-block; padding: 0 6px; border-radius: 8px; background: var(--raised, rgba(255,255,255,.12)); color: var(--fg, #eee); font-size: 11px; }',
|
|
'#tab-sources .src-kind-audiobook, #tab-sources .src-kind-youtube { background: var(--accent, #e63946); color: var(--on-accent, #fff); }',
|
|
'#tab-sources .src-actions { padding: 0 0 12px 50px; }',
|
|
'#tab-sources .src-io { margin-top: 22px; }',
|
|
'#tab-sources .src-io summary { cursor: pointer; color: var(--muted, #9a9a9a); font-size: 13px; padding: 6px 0; }',
|
|
'#src-toast { position: fixed; left: 50%; bottom: calc(var(--nui-dock-h, 0px) + 90px); transform: translateX(-50%); z-index: 2500; background: var(--raised, #222); color: var(--fg, #eee); padding: 10px 16px; border-radius: 8px; font-size: 14px; opacity: 0; pointer-events: none; transition: opacity .2s; max-width: calc(100vw - 32px); }',
|
|
'#src-toast.src-show { opacity: 1; }',
|
|
'body.eink #tab-sources .src-primary, body.eink #tab-sources .src-chip.on { background: #000; color: #fff; }',
|
|
].join('\n');
|
|
document.head.appendChild(style);
|
|
})();
|