mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-07 19:21:32 -07:00
feat(anime): show extension and repo icons in extensions panel
- Add extension-icons module (buildIconIndex, isSafeIconUrl, repoFaviconUrl, iconMonogram) with tests - Render each row's icon with a monogram fallback while loading or missing - Installed extensions borrow their icon from the catalogue; repo rows use the index host's favicon - Restrict icon loads to https URLs - Thread iconUrl through AvailableExtension and the runtime, document the feature
This commit is contained in:
@@ -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
|
**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.
|
`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
|
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
|
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;
|
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**
|
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
|
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
|
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;
|
**Update** appears next to an extension a configured repository still carries;
|
||||||
it downloads the current version over the existing APK.
|
it downloads the current version over the existing APK.
|
||||||
|
|||||||
@@ -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('---'), '?');
|
||||||
|
});
|
||||||
@@ -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<string, string> {
|
||||||
|
const index = new Map<string, string>();
|
||||||
|
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 '?';
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, el } from './dom';
|
import { describe, el } from './dom';
|
||||||
|
import { buildIconIndex, iconMonogram, isSafeIconUrl, repoFaviconUrl } from './extension-icons';
|
||||||
import { describeInstalled } from './format';
|
import { describeInstalled } from './format';
|
||||||
import {
|
import {
|
||||||
collectLanguages,
|
collectLanguages,
|
||||||
@@ -39,14 +40,40 @@ interface RowAction {
|
|||||||
interface RowOptions {
|
interface RowOptions {
|
||||||
name: string;
|
name: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
|
/** Repository-published icon for the row, when there is one. */
|
||||||
|
iconUrl?: string | null;
|
||||||
tags?: Array<{ text: string; className: string }>;
|
tags?: Array<{ text: string; className: string }>;
|
||||||
actions?: RowAction[];
|
actions?: RowAction[];
|
||||||
isError?: boolean;
|
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 {
|
function extensionRow(options: RowOptions): HTMLDivElement {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = options.isError ? 'ext-row is-error' : 'ext-row';
|
row.className = options.isError ? 'ext-row is-error' : 'ext-row';
|
||||||
|
row.append(extensionIcon(options.name, options.iconUrl));
|
||||||
|
|
||||||
const main = document.createElement('div');
|
const main = document.createElement('div');
|
||||||
main.className = 'ext-main';
|
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
|
// What the last refresh found, kept so toggling a language chip re-renders
|
||||||
// the list without re-fetching every repository index.
|
// the list without re-fetching every repository index.
|
||||||
let installable: AvailableExtension[] = [];
|
let installable: AvailableExtension[] = [];
|
||||||
|
/** Package to icon URL, so installed rows can borrow the catalogue's icon. */
|
||||||
|
let iconsByPkg = new Map<string, string>();
|
||||||
let repoFailures: Array<{ name: string; error: string }> = [];
|
let repoFailures: Array<{ name: string; error: string }> = [];
|
||||||
let hasRepos = false;
|
let hasRepos = false;
|
||||||
/** Selected language codes; empty means "All". */
|
/** Selected language codes; empty means "All". */
|
||||||
@@ -168,6 +197,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
|||||||
return extensionRow({
|
return extensionRow({
|
||||||
name: view.name,
|
name: view.name,
|
||||||
sub: view.error ?? describeInstalled(view),
|
sub: view.error ?? describeInstalled(view),
|
||||||
|
iconUrl: iconsByPkg.get(view.pkg) ?? null,
|
||||||
isError: view.error !== null,
|
isError: view.error !== null,
|
||||||
tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }],
|
tags: view.error === null ? [] : [{ text: 'failed', className: 'nsfw' }],
|
||||||
actions,
|
actions,
|
||||||
@@ -182,6 +212,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
|||||||
extensionRow({
|
extensionRow({
|
||||||
name: repoUrl.replace(/^https:\/\//, '').replace(/\/[^/]*\.json$/, ''),
|
name: repoUrl.replace(/^https:\/\//, '').replace(/\/[^/]*\.json$/, ''),
|
||||||
sub: repoUrl,
|
sub: repoUrl,
|
||||||
|
iconUrl: repoFaviconUrl(repoUrl),
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
label: 'Remove',
|
label: 'Remove',
|
||||||
@@ -258,6 +289,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
|||||||
extensionRow({
|
extensionRow({
|
||||||
name: extension.name,
|
name: extension.name,
|
||||||
sub: `${languageLabel(extension.lang)} · v${extension.version}`,
|
sub: `${languageLabel(extension.lang)} · v${extension.version}`,
|
||||||
|
iconUrl: extension.iconUrl,
|
||||||
tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [],
|
tags: extension.nsfw ? [{ text: '18+', className: 'nsfw' }] : [],
|
||||||
actions: [
|
actions: [
|
||||||
{
|
{
|
||||||
@@ -311,6 +343,9 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg));
|
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);
|
renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir);
|
||||||
// Installed extensions have their own section; leaving them here too would
|
// Installed extensions have their own section; leaving them here too would
|
||||||
// list every one of them twice.
|
// list every one of them twice.
|
||||||
|
|||||||
@@ -211,6 +211,47 @@
|
|||||||
border-color: rgba(237, 135, 150, 0.45);
|
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 {
|
.ext-main {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -389,6 +389,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
|||||||
version: extension.version,
|
version: extension.version,
|
||||||
nsfw: extension.nsfw,
|
nsfw: extension.nsfw,
|
||||||
repoUrl: extension.repoUrl,
|
repoUrl: extension.repoUrl,
|
||||||
|
iconUrl: extension.iconUrl,
|
||||||
sourceNames: extension.sourceNames,
|
sourceNames: extension.sourceNames,
|
||||||
installed: installedPkgs.has(extension.pkg),
|
installed: installedPkgs.has(extension.pkg),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -156,6 +156,8 @@ export interface AvailableExtension {
|
|||||||
version: string;
|
version: string;
|
||||||
nsfw: boolean;
|
nsfw: boolean;
|
||||||
repoUrl: string;
|
repoUrl: string;
|
||||||
|
/** Where the repository publishes the extension's icon; may 404. */
|
||||||
|
iconUrl: string;
|
||||||
sourceNames: string[];
|
sourceNames: string[];
|
||||||
installed: boolean;
|
installed: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user