diff --git a/docs-site/anime-browser.md b/docs-site/anime-browser.md index 679fe02e..5819736e 100644 --- a/docs-site/anime-browser.md +++ b/docs-site/anime-browser.md @@ -58,6 +58,12 @@ Extensions your repositories offer but you do not have appear under **Available**, each with **Install**. Repositories are stored in config under `anime.repos`, so you can also manage them there and keep them in a dotfile. +Every row carries the extension's icon, as the repository publishes it, so a +site is recognisable before you read the name. A repository row shows its host's +favicon instead. Icons are the only part of a row that is fetched from the +network, and a row whose icon is missing falls back to the first letter of its +name rather than an empty box. + A repository index lists every language it knows about, which is far more than any one person reads, so the **Available** list has a language chip row above it. Pick one or more languages to narrow it, or **All** to clear the filter; @@ -72,7 +78,8 @@ The Extensions tab opens with an **Installed** section listing everything in the extensions directory, with the sources each one provides and a **Remove** button. It is built from the directory rather than from a repository, so an extension you dropped in by hand — or one whose repository you have since -removed — is still listed and still removable. +removed — is still listed and still removable. An installed extension borrows +its icon from the catalogue, so one no repository carries shows its monogram. **Update** appears next to an extension a configured repository still carries; it downloads the current version over the existing APK. diff --git a/src/animeui/extension-icons.test.ts b/src/animeui/extension-icons.test.ts new file mode 100644 index 00000000..f5573f74 --- /dev/null +++ b/src/animeui/extension-icons.test.ts @@ -0,0 +1,42 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { buildIconIndex, iconMonogram, isSafeIconUrl, repoFaviconUrl } from './extension-icons'; + +test('buildIconIndex maps packages to their icon, skipping empty URLs', () => { + const index = buildIconIndex([ + { pkg: 'a.b.c', iconUrl: 'https://repo.example/icon/a.b.c.png' }, + { pkg: 'd.e.f', iconUrl: '' }, + ]); + assert.equal(index.get('a.b.c'), 'https://repo.example/icon/a.b.c.png'); + assert.equal(index.has('d.e.f'), false); +}); + +test('isSafeIconUrl accepts https only', () => { + assert.equal(isSafeIconUrl('https://repo.example/icon/x.png'), true); + assert.equal(isSafeIconUrl('http://repo.example/icon/x.png'), false); + assert.equal(isSafeIconUrl('javascript:alert(1)'), false); + assert.equal(isSafeIconUrl(null), false); + assert.equal(isSafeIconUrl(undefined), false); +}); + +test('repoFaviconUrl points at the index host, https only', () => { + assert.equal( + repoFaviconUrl(' https://raw.githubusercontent.com/u/r/main/index.min.json '), + 'https://raw.githubusercontent.com/favicon.ico', + ); + assert.equal(repoFaviconUrl('http://repo.example/index.json'), null); + assert.equal(repoFaviconUrl('not a url'), null); +}); + +test('iconMonogram takes the first letter or digit, uppercased', () => { + assert.equal(iconMonogram('AllAnime'), 'A'); + assert.equal(iconMonogram(' gogoanime'), 'G'); + assert.equal(iconMonogram('» Foo'), 'F'); + assert.equal(iconMonogram('9anime'), '9'); + assert.equal(iconMonogram('アニメ'), 'ア'); +}); + +test('iconMonogram falls back to a placeholder for a nameless row', () => { + assert.equal(iconMonogram(''), '?'); + assert.equal(iconMonogram('---'), '?'); +}); diff --git a/src/animeui/extension-icons.ts b/src/animeui/extension-icons.ts new file mode 100644 index 00000000..fc128e7e --- /dev/null +++ b/src/animeui/extension-icons.ts @@ -0,0 +1,61 @@ +/** + * Icons for the extension rows. + * + * An Aniyomi repository publishes each extension's icon — the streaming site's + * own favicon — next to its APK, so the repository index is the only place an + * icon can come from. Installed extensions are listed from disk and carry no + * icon of their own; they borrow the one their package has in the catalogue, + * which means an extension whose repository was removed, or one dropped in by + * hand, falls back to a monogram. + */ + +interface IconSource { + pkg: string; + iconUrl: string; +} + +/** Package name to icon URL, for looking up an installed extension's icon. */ +export function buildIconIndex(extensions: IconSource[]): Map { + const index = new Map(); + for (const extension of extensions) { + if (typeof extension.iconUrl === 'string' && extension.iconUrl.length > 0) { + index.set(extension.pkg, extension.iconUrl); + } + } + return index; +} + +/** Only https icons are loaded; a repo index is content the user pointed us at. */ +export function isSafeIconUrl(url: string | null | undefined): url is string { + return typeof url === 'string' && /^https:\/\/\S+$/.test(url); +} + +/** + * The favicon of the host serving a repository index. + * + * A repository publishes no icon for itself, so the host's own favicon stands + * in. Nothing depends on it: a host that serves none simply leaves the row on + * its monogram. + */ +export function repoFaviconUrl(indexUrl: string): string | null { + try { + const url = new URL(indexUrl.trim()); + if (url.protocol !== 'https:') return null; + return `https://${url.host}/favicon.ico`; + } catch { + return null; + } +} + +/** + * The letter shown while an icon loads, or in place of one that never does. + * + * Leading punctuation and whitespace are skipped, and a name that carries no + * letter or digit at all still gets a placeholder rather than an empty box. + */ +export function iconMonogram(name: string): string { + for (const char of name.trim()) { + if (/[\p{L}\p{N}]/u.test(char)) return char.toUpperCase(); + } + return '?'; +} diff --git a/src/animeui/extensions-panel.ts b/src/animeui/extensions-panel.ts index 2280285f..4e81aa41 100644 --- a/src/animeui/extensions-panel.ts +++ b/src/animeui/extensions-panel.ts @@ -1,4 +1,5 @@ import { describe, el } from './dom'; +import { buildIconIndex, iconMonogram, isSafeIconUrl, repoFaviconUrl } from './extension-icons'; import { describeInstalled } from './format'; import { collectLanguages, @@ -39,14 +40,40 @@ interface RowAction { interface RowOptions { name: string; sub: string; + /** Repository-published icon for the row, when there is one. */ + iconUrl?: string | null; tags?: Array<{ text: string; className: string }>; actions?: RowAction[]; isError?: boolean; } +/** + * The row's avatar: the extension's icon, with the name's first letter behind + * it. Repositories do not always publish an icon for every package, so the + * monogram shows until the image loads and stays put if it never does. + */ +function extensionIcon(name: string, iconUrl: string | null | undefined): HTMLSpanElement { + const badge = document.createElement('span'); + badge.className = 'ext-icon'; + badge.setAttribute('aria-hidden', 'true'); + badge.textContent = iconMonogram(name); + if (!isSafeIconUrl(iconUrl)) return badge; + + const image = document.createElement('img'); + image.className = 'ext-icon-img'; + image.loading = 'lazy'; + image.alt = ''; + image.addEventListener('load', () => badge.classList.add('has-icon')); + image.addEventListener('error', () => image.remove()); + image.src = iconUrl; + badge.append(image); + return badge; +} + function extensionRow(options: RowOptions): HTMLDivElement { const row = document.createElement('div'); row.className = options.isError ? 'ext-row is-error' : 'ext-row'; + row.append(extensionIcon(options.name, options.iconUrl)); const main = document.createElement('div'); main.className = 'ext-main'; @@ -106,6 +133,8 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { // What the last refresh found, kept so toggling a language chip re-renders // the list without re-fetching every repository index. let installable: AvailableExtension[] = []; + /** Package to icon URL, so installed rows can borrow the catalogue's icon. */ + let iconsByPkg = new Map(); let repoFailures: Array<{ name: string; error: string }> = []; let hasRepos = false; /** Selected language codes; empty means "All". */ @@ -168,6 +197,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { return extensionRow({ name: view.name, sub: view.error ?? describeInstalled(view), + iconUrl: iconsByPkg.get(view.pkg) ?? null, isError: view.error !== null, tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }], actions, @@ -182,6 +212,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { extensionRow({ name: repoUrl.replace(/^https:\/\//, '').replace(/\/[^/]*\.json$/, ''), sub: repoUrl, + iconUrl: repoFaviconUrl(repoUrl), actions: [ { label: 'Remove', @@ -258,6 +289,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { extensionRow({ name: extension.name, sub: `${languageLabel(extension.lang)} · v${extension.version}`, + iconUrl: extension.iconUrl, tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [], actions: [ { @@ -311,6 +343,9 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) { } const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg)); + // The catalogue is the only source of icons, so an installed extension can + // only show one while a repository still carries its package. + iconsByPkg = buildIconIndex(available.extensions); renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir); // Installed extensions have their own section; leaving them here too would // list every one of them twice. diff --git a/src/animeui/panels.css b/src/animeui/panels.css index b0e3978f..544c137c 100644 --- a/src/animeui/panels.css +++ b/src/animeui/panels.css @@ -211,6 +211,47 @@ border-color: rgba(237, 135, 150, 0.45); } +/* + * Avatar for an extension row. The monogram is the box's own text, so it shows + * while the icon loads and stays put when a repository publishes none; a loaded + * icon covers it. + */ +.ext-icon { + position: relative; + flex: none; + display: grid; + place-items: center; + width: 28px; + height: 28px; + border-radius: 8px; + border: 1px solid var(--line); + background: var(--ctp-surface0); + font-size: 13px; + font-weight: 600; + color: var(--faint); + overflow: hidden; +} + +/* A loaded icon replaces the monogram; transparent PNGs must not show it. */ +.ext-icon.has-icon { + border-color: transparent; + background: transparent; + color: transparent; +} + +.ext-icon-img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: contain; + opacity: 0; +} + +.ext-icon.has-icon .ext-icon-img { + opacity: 1; +} + .ext-main { flex: 1 1 auto; min-width: 0; diff --git a/src/main/runtime/anime-browser-runtime.ts b/src/main/runtime/anime-browser-runtime.ts index b57783f2..786928a3 100644 --- a/src/main/runtime/anime-browser-runtime.ts +++ b/src/main/runtime/anime-browser-runtime.ts @@ -389,6 +389,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) { version: extension.version, nsfw: extension.nsfw, repoUrl: extension.repoUrl, + iconUrl: extension.iconUrl, sourceNames: extension.sourceNames, installed: installedPkgs.has(extension.pkg), })), diff --git a/src/types/anime-browser.ts b/src/types/anime-browser.ts index b50c3216..19e7231b 100644 --- a/src/types/anime-browser.ts +++ b/src/types/anime-browser.ts @@ -156,6 +156,8 @@ export interface AvailableExtension { version: string; nsfw: boolean; repoUrl: string; + /** Where the repository publishes the extension's icon; may 404. */ + iconUrl: string; sourceNames: string[]; installed: boolean; }