fix(web): use OOMOL-hosted icons & add notice in console

fixes #231
This commit is contained in:
hyrious
2026-08-03 09:23:53 +08:00
parent a7e958e5f8
commit 368eba88b0
14 changed files with 141 additions and 126 deletions
+2
View File
@@ -1,7 +1,9 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vitest/config";
import { providerIconsPlugin } from "./web/provider-icons-plugin";
export default defineConfig({
plugins: [providerIconsPlugin({ iconUrls: {} })],
resolve: {
alias: {
"@": fileURLToPath(new URL("./web/src", import.meta.url)),
+64
View File
@@ -0,0 +1,64 @@
import type { Plugin } from "vite";
const catalogUrl = "https://oomol.com/en/apps/catalog.json";
const publicModuleId = "virtual:oomol-provider-icons";
const resolvedModuleId = `\0${publicModuleId}`;
interface CatalogItem {
iconUrl?: unknown;
service?: unknown;
}
interface CatalogPayload {
items?: unknown;
}
interface ProviderIconsPluginOptions {
iconUrls?: Readonly<Record<string, string>>;
}
/** Bundles OOMOL's provider icon mapping and caches it for the lifetime of the Vite process. */
export function providerIconsPlugin(options: ProviderIconsPluginOptions = {}): Plugin {
let cachedModule: Promise<string> | undefined;
return {
name: "oomol-provider-icons",
resolveId(id): string | undefined {
return id === publicModuleId ? resolvedModuleId : undefined;
},
load(id): Promise<string> | undefined {
if (id !== resolvedModuleId) {
return undefined;
}
cachedModule ??= options.iconUrls
? Promise.resolve(serializeProviderIcons(options.iconUrls))
: loadProviderIconsModule();
return cachedModule;
},
};
}
async function loadProviderIconsModule(): Promise<string> {
const response = await fetch(catalogUrl);
if (!response.ok) {
throw new Error(`Could not load OOMOL provider icons: ${response.status} ${response.statusText}`);
}
const payload = (await response.json()) as CatalogPayload;
if (!Array.isArray(payload.items)) {
throw new Error("Could not load OOMOL provider icons: catalog.items is not an array");
}
const iconUrls: Record<string, string> = {};
for (const item of payload.items as CatalogItem[]) {
if (typeof item.service === "string" && typeof item.iconUrl === "string" && item.iconUrl.trim()) {
iconUrls[item.service] = item.iconUrl;
}
}
return serializeProviderIcons(iconUrls);
}
function serializeProviderIcons(iconUrls: Readonly<Record<string, string>>): string {
return `export default ${JSON.stringify(iconUrls)};`;
}
+1
View File
@@ -121,6 +121,7 @@
},
"providers": {
"catalogTitle": "Providers",
"brandNotice": "Third-party names, logos, and trademarks belong to their respective owners and are displayed only to identify the corresponding providers. This project does not claim ownership of them. Users who redistribute or commercialize this project are responsible for reviewing the applicable brand guidelines and obtaining any permissions required for their intended use.",
"searchPlaceholder": "Search providers",
"statusFilterLabel": "Provider status",
"filters": {
+1
View File
@@ -121,6 +121,7 @@
},
"providers": {
"catalogTitle": "Fournisseurs",
"brandNotice": "Les noms, logos et marques de tiers appartiennent à leurs propriétaires respectifs et sont affichés uniquement pour identifier les fournisseurs correspondants. Ce projet ne revendique aucun droit sur ceux-ci. Les utilisateurs qui redistribuent ou commercialisent ce projet doivent consulter les règles applicables aux marques et obtenir les autorisations nécessaires à leur usage.",
"searchPlaceholder": "Rechercher des fournisseurs",
"statusFilterLabel": "Statut du fournisseur",
"filters": {
+1
View File
@@ -121,6 +121,7 @@
},
"providers": {
"catalogTitle": "プロバイダー",
"brandNotice": "第三者の名称、ロゴ、および商標は各権利者に帰属し、対応するプロバイダーを識別する目的でのみ表示されています。本プロジェクトはこれらの権利を主張しません。本プロジェクトを再配布または商用利用する場合、利用者の責任で該当するブランドガイドラインを確認し、用途に必要な許可を取得してください。",
"searchPlaceholder": "プロバイダーを検索",
"statusFilterLabel": "プロバイダーのステータス",
"filters": {
+1
View File
@@ -121,6 +121,7 @@
},
"providers": {
"catalogTitle": "Провайдеры",
"brandNotice": "Названия, логотипы и товарные знаки третьих лиц принадлежат их соответствующим владельцам и отображаются только для идентификации провайдеров. Проект не претендует на права на них. Пользователи, распространяющие или коммерциализирующие проект, обязаны самостоятельно изучить применимые правила использования бренда и получить необходимые для их целей разрешения.",
"searchPlaceholder": "Искать провайдеры",
"statusFilterLabel": "Статус провайдера",
"filters": {
+1
View File
@@ -121,6 +121,7 @@
},
"providers": {
"catalogTitle": "提供商",
"brandNotice": "第三方名称、标志及商标归其各自权利人所有,此处仅用于识别对应的提供商。本项目不主张拥有相关权利。再分发本项目或将其用于商业用途的用户,应自行查阅适用的品牌规范,并按其具体用途取得所需许可。",
"searchPlaceholder": "搜索提供商",
"statusFilterLabel": "提供商状态",
"filters": {
+1
View File
@@ -121,6 +121,7 @@
},
"providers": {
"catalogTitle": "服務提供者",
"brandNotice": "第三方名稱、標誌及商標歸其各自權利人所有,此處僅用於識別對應的服務提供者。本專案不主張擁有相關權利。再散布本專案或將其用於商業用途的使用者,應自行查閱適用的品牌規範,並依其具體用途取得所需許可。",
"searchPlaceholder": "搜尋服務提供者",
"statusFilterLabel": "服務提供者狀態",
"filters": {
+2
View File
@@ -225,6 +225,8 @@ function ProviderBrowser(props: ProviderBrowserProps): ReactNode {
onSelect={setStatusFilter}
/>
<p className="provider-brand-notice">{t("providers.brandNotice")}</p>
{visibleProviders.length === 0 ? (
<div className="provider-empty-row">
<EmptyState title={t("providers.noProvidersTitle")} description={t("providers.noProvidersDescription")} />
+28 -76
View File
@@ -1,93 +1,45 @@
import type { ProviderDefinition } from "./model";
import { describe, expect, it } from "vitest";
import { resolveProviderIconClass } from "./shared-ui";
import { providerIconSource } from "./shared-ui";
function provider(options: { service: string; displayName: string; homepageUrl?: string }): ProviderDefinition {
function provider(options: Partial<ProviderDefinition> & Pick<ProviderDefinition, "service">): ProviderDefinition {
return {
service: options.service,
displayName: options.displayName,
displayName: options.displayName ?? options.service,
categories: [],
authTypes: [],
auth: [],
homepageUrl: options.homepageUrl,
actions: [],
...options,
service: options.service,
};
}
describe("resolveProviderIconClass", () => {
it("uses product-specific logo classes for mapped Google providers", () => {
const mappedProviders = [
{
service: "google_analytics",
displayName: "Google Analytics",
homepageUrl: "https://analytics.google.com",
expected: "i-logos-google-analytics",
},
{
service: "gmail",
displayName: "Gmail",
homepageUrl: "https://mail.google.com",
expected: "i-logos-google-gmail",
},
{
service: "googlephotos",
displayName: "Google Photos",
homepageUrl: "https://www.google.com/photos/about/",
expected: "i-logos-google-photos",
},
{
service: "google_search_console",
displayName: "Google Search Console",
homepageUrl: "https://search.google.com/search-console",
expected: "i-logos-google-search-console",
},
{
service: "google_cloud_sts",
displayName: "Google Cloud STS",
homepageUrl: "https://cloud.google.com/iam/docs/workload-identity-federation",
expected: "i-logos-google-cloud",
},
{
service: "googledrive",
displayName: "Google Drive",
homepageUrl: "https://workspace.google.com/products/drive/",
expected: "i-logos-google-drive",
},
{
service: "googlecalendar",
displayName: "Google Calendar",
homepageUrl: "https://workspace.google.com/products/calendar/",
expected: "i-logos-google-calendar",
},
{
service: "google_address_validation",
displayName: "Google Address Validation",
homepageUrl: "https://developers.google.com/maps/documentation/address-validation",
expected: "i-logos-google-maps",
},
{
service: "google_routes",
displayName: "Google Routes",
homepageUrl: "https://developers.google.com/maps/documentation/routes",
expected: "i-logos-google-maps",
},
];
for (const mappedProvider of mappedProviders) {
expect(resolveProviderIconClass(provider(mappedProvider))).toBe(mappedProvider.expected);
}
describe("providerIconSource", () => {
it("prefers the provider definition icon", () => {
expect(
providerIconSource(provider({ service: "example", iconUrl: " https://example.com/icon.svg " }), {
example: "https://static.oomol.com/example.svg",
}),
).toEqual({ kind: "url", value: "https://example.com/icon.svg" });
});
it("uses the default Google logo class for unmapped Google providers", () => {
it("uses the bundled OOMOL catalog icon", () => {
expect(
resolveProviderIconClass(
provider({
service: "google_bigquery",
displayName: "Google BigQuery",
homepageUrl: "https://cloud.google.com/bigquery",
}),
),
).toBe("i-logos-google-icon");
providerIconSource(provider({ service: "example" }), {
example: "https://static.oomol.com/example.svg",
}),
).toEqual({ kind: "url", value: "https://static.oomol.com/example.svg" });
});
it("uses Google's favicon service when no icon is mapped", () => {
expect(providerIconSource(provider({ service: "example", homepageUrl: "https://example.com/docs" }), {})).toEqual({
kind: "url",
value: "https://www.google.com/s2/favicons?sz=64&domain=example.com",
});
});
it("falls back to initials when no icon or valid homepage is available", () => {
expect(providerIconSource(provider({ service: "example", homepageUrl: "not a URL" }), {})).toBeUndefined();
});
});
+12 -49
View File
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { CircleAlert, Inbox } from "lucide-react";
import { useState } from "react";
import providerIconUrls from "virtual:oomol-provider-icons";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge as UiBadge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
@@ -61,14 +62,6 @@ export function ProviderIcon(props: { provider: ProviderDefinition; large?: bool
return <span className={className}>{letters}</span>;
}
if (iconSource.kind == "class") {
return (
<span className={className}>
<i aria-hidden="true" className={`provider-icon-css-icon ${iconSource.value}`} />
</span>
);
}
return (
<span className={className}>
<img
@@ -100,58 +93,28 @@ export function providerIconUrl(provider: ProviderDefinition): string | undefine
}
interface ProviderIconSource {
kind: "url" | "class";
kind: "url";
value: string;
}
const defaultGoogleProviderIconClass = "i-logos-google-icon";
const googleProviderIconClasses: Record<string, string> = {
google_analytics: "i-logos-google-analytics",
gmail: "i-logos-google-gmail",
googlephotos: "i-logos-google-photos",
google_search_console: "i-logos-google-search-console",
google_cloud_sts: "i-logos-google-cloud",
googledrive: "i-logos-google-drive",
googlecalendar: "i-logos-google-calendar",
google_address_validation: "i-logos-google-maps",
google_routes: "i-logos-google-maps",
};
export function providerIconSource(provider: ProviderDefinition): ProviderIconSource | undefined {
export function providerIconSource(
provider: ProviderDefinition,
catalogIconUrls: Readonly<Record<string, string>> = providerIconUrls,
): ProviderIconSource | undefined {
const iconUrl = provider.iconUrl?.trim();
if (iconUrl) {
return { kind: "url", value: iconUrl };
}
const resolvedIconClass = resolveProviderIconClass(provider);
if (resolvedIconClass) {
return { kind: "class", value: resolvedIconClass };
}
if (import.meta.env.VITE_PROVIDER_ICON_FAVICON_FALLBACK === "false") {
return undefined;
const catalogIconUrl = catalogIconUrls[provider.service]?.trim();
if (catalogIconUrl) {
return { kind: "url", value: catalogIconUrl };
}
const hostname = providerHomepageHostname(provider.homepageUrl);
if (!hostname) {
return undefined;
}
return { kind: "url", value: `https://www.google.com/s2/favicons?sz=64&domain=${encodeURIComponent(hostname)}` };
}
export function resolveProviderIconClass(provider: ProviderDefinition): string | undefined {
const iconClass = googleProviderIconClasses[provider.service];
if (iconClass) {
return iconClass;
}
if (provider.homepageUrl?.toLowerCase().includes("google")) {
return defaultGoogleProviderIconClass;
}
return undefined;
return hostname
? { kind: "url", value: `https://www.google.com/s2/favicons?sz=64&domain=${encodeURIComponent(hostname)}` }
: undefined;
}
function providerHomepageHostname(homepageUrl: string | undefined): string | undefined {
+20 -1
View File
@@ -1,6 +1,6 @@
.provider-browser-panel {
min-height: 0;
overflow: hidden;
overflow: clip;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--card);
@@ -144,6 +144,25 @@
padding: 12px;
}
.provider-brand-notice {
position: sticky;
z-index: 2;
top: 56px;
margin: 0;
padding: 8px 16px;
border-bottom: 1px solid var(--border);
background: color-mix(in oklab, var(--card) 94%, var(--muted));
color: var(--muted-foreground);
font-size: 11px;
line-height: 1.4;
}
@media (max-width: 960px) {
.provider-brand-notice {
position: static;
}
}
.provider-empty-row {
display: grid;
place-items: center;
+5
View File
@@ -1 +1,6 @@
declare module "virtual:uno.css" {}
declare module "virtual:oomol-provider-icons" {
const iconUrls: Readonly<Record<string, string>>;
export default iconUrls;
}
+2
View File
@@ -4,6 +4,7 @@ import UnoCSS from "@unocss/vite";
import react from "@vitejs/plugin-react";
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import { providerIconsPlugin } from "./provider-icons-plugin";
export default defineConfig({
plugins: [
@@ -12,6 +13,7 @@ export default defineConfig({
UnoCSS({
presets: [presetIcons()],
}),
providerIconsPlugin(),
],
resolve: {
alias: {