setImgError(true)}
/>
@@ -3026,10 +3046,14 @@ function ServiceIcon({ service: s }: { service: Service }) {
function ServiceRow({
service: s,
+ iconManifest,
+ isDark,
expanded,
onToggle,
}: {
service: Service;
+ iconManifest: IconManifest;
+ isDark: boolean;
expanded: boolean;
onToggle: () => void;
}) {
@@ -3074,7 +3098,11 @@ function ServiceRow({
paddingTop: "0.15rem",
}}
>
-
{
+ const check = () => {
+ const scheme = document.documentElement.style.colorScheme;
+ setDark(
+ scheme === "dark" ||
+ (!scheme &&
+ window.matchMedia("(prefers-color-scheme: dark)").matches),
+ );
+ };
+ check();
+ const observer = new MutationObserver(check);
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ["style"],
+ });
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
+ mq.addEventListener("change", check);
+ return () => {
+ observer.disconnect();
+ mq.removeEventListener("change", check);
+ };
+ }, []);
+ return dark;
+}
+
+// ---------------------------------------------------------------------------
+// Icon manifest (transparency + background data from sync-logos)
+// ---------------------------------------------------------------------------
+
+export interface IconManifest {
+ transparent: Set;
+ lightBg: Set;
+}
+
+const EMPTY_MANIFEST: IconManifest = {
+ transparent: new Set(),
+ lightBg: new Set(),
+};
+
+let manifestCache: { data: IconManifest; ts: number } | null = null;
+let manifestInflight: Promise | null = null;
+
+export async function fetchIconManifest(): Promise {
+ if (manifestCache && Date.now() - manifestCache.ts < CACHE_TTL_MS) {
+ return manifestCache.data;
+ }
+
+ if (manifestInflight) return manifestInflight;
+
+ manifestInflight = fetch("/api/icon-manifest")
+ .then((res) => (res.ok ? res.json() : { transparent: [], lightBg: [] }))
+ .then((json: { transparent: string[]; lightBg?: string[] }) => {
+ const manifest: IconManifest = {
+ transparent: new Set(json.transparent),
+ lightBg: new Set(json.lightBg ?? []),
+ };
+ manifestCache = { data: manifest, ts: Date.now() };
+ manifestInflight = null;
+ return manifest;
+ })
+ .catch(() => {
+ manifestInflight = null;
+ return EMPTY_MANIFEST;
+ });
+
+ return manifestInflight;
+}
+
+// ---------------------------------------------------------------------------
+// Fetch
+// ---------------------------------------------------------------------------
+
export async function fetchServices(): Promise {
if (cached && Date.now() - cached.ts < CACHE_TTL_MS) {
return cached.data;
diff --git a/src/pages/_api/api/icon-manifest.ts b/src/pages/_api/api/icon-manifest.ts
new file mode 100644
index 00000000..196ea763
--- /dev/null
+++ b/src/pages/_api/api/icon-manifest.ts
@@ -0,0 +1,28 @@
+import { list } from "@vercel/blob";
+
+const BLOB_TOKEN = process.env.BLOB_READ_WRITE_TOKEN;
+
+export async function GET() {
+ if (!BLOB_TOKEN) return Response.json({ transparent: [], lightBg: [] });
+ try {
+ const { blobs } = await list({
+ prefix: "logos/_manifest.json",
+ limit: 1,
+ token: BLOB_TOKEN,
+ });
+ if (blobs.length > 0) {
+ const res = await fetch(blobs[0].url);
+ if (res.ok) {
+ const data = await res.json();
+ return Response.json(data, {
+ headers: {
+ "Cache-Control": "public, s-maxage=3600, stale-while-revalidate",
+ },
+ });
+ }
+ }
+ } catch (e) {
+ console.error("[icon-manifest] error:", e);
+ }
+ return Response.json({ transparent: [], lightBg: [] });
+}
diff --git a/src/pages/_api/api/icon.ts b/src/pages/_api/api/icon.ts
index 5bc72cf6..8216492b 100644
--- a/src/pages/_api/api/icon.ts
+++ b/src/pages/_api/api/icon.ts
@@ -1,12 +1,9 @@
-import { list, put } from "@vercel/blob";
-import discovery from "../../../../schemas/discovery.json";
+import { list } from "@vercel/blob";
-const LOGOLINK_KEY = process.env.BRANDDEV_LOGOLINK_KEY;
const BLOB_TOKEN = process.env.BLOB_READ_WRITE_TOKEN;
-const SVG_HEADERS = {
- "Content-Type": "image/svg+xml",
- "Cache-Control": "public, s-maxage=31536000, stale-while-revalidate",
+const CACHE_HEADERS = {
+ "Cache-Control": "public, s-maxage=86400, stale-while-revalidate",
};
const FALLBACK_HEADERS = {
@@ -14,133 +11,79 @@ const FALLBACK_HEADERS = {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate",
};
-interface ServiceEntry {
- id: string;
- name: string;
- url: string;
- provider?: { name?: string; url?: string };
-}
-
-// ---------------------------------------------------------------------------
-// Helpers
-// ---------------------------------------------------------------------------
-
-function domainFor(service: ServiceEntry): string | null {
- const raw = service.provider?.url ?? service.url;
- try {
- return new URL(raw).hostname;
- } catch {
- return null;
- }
-}
-
-function styledSvg(logoDataUri: string): string {
- return ``;
+function letterSvg(id: string): string {
+ const letter = (id[0] ?? "?").toUpperCase();
+ return ``;
}
-function letterSvg(name: string): string {
- const letter = (name[0] ?? "?").toUpperCase();
- return ``;
-}
-
-// ---------------------------------------------------------------------------
-// Vercel Blob helpers
-// ---------------------------------------------------------------------------
-
-async function blobGet(id: string): Promise {
+async function blobGet(
+ id: string,
+): Promise<{ body: ReadableStream; contentType: string } | null> {
if (!BLOB_TOKEN) return null;
try {
- const { blobs } = await list({
- prefix: `icons/${id}.svg`,
- limit: 1,
- token: BLOB_TOKEN,
- });
- if (blobs.length === 0) return null;
- const res = await fetch(blobs[0].url);
- return res.ok ? res.text() : null;
+ for (const ext of ["png", "svg"]) {
+ const { blobs } = await list({
+ prefix: `logos/${id}.${ext}`,
+ limit: 1,
+ token: BLOB_TOKEN,
+ });
+ if (blobs.length > 0) {
+ const res = await fetch(blobs[0].url);
+ if (res.ok && res.body) {
+ const ct =
+ res.headers.get("content-type") ??
+ (ext === "svg" ? "image/svg+xml" : `image/${ext}`);
+ return { body: res.body, contentType: ct };
+ }
+ }
+ }
} catch (e) {
console.error(`[icon] blob read error for ${id}:`, e);
- return null;
- }
-}
-
-async function blobPut(id: string, svg: string): Promise {
- if (!BLOB_TOKEN) return;
- try {
- await put(`icons/${id}.svg`, svg, {
- access: "public",
- contentType: "image/svg+xml",
- addRandomSuffix: false,
- token: BLOB_TOKEN,
- });
- } catch (e) {
- console.error(`[icon] blob write error for ${id}:`, e);
}
+ return null;
}
-// ---------------------------------------------------------------------------
-// brand.dev Logo Link
-// ---------------------------------------------------------------------------
-
-async function fetchLogo(domain: string): Promise {
- if (!LOGOLINK_KEY) return null;
+async function staticFallback(
+ request: Request,
+ id: string,
+): Promise {
try {
- const res = await fetch(
- `https://logos.brand.dev/?publicClientId=${LOGOLINK_KEY}&domain=${domain}`,
- );
- if (!res.ok) return null;
- const ct = res.headers.get("content-type") ?? "image/png";
- const buf = await res.arrayBuffer();
- return `data:${ct};base64,${Buffer.from(buf).toString("base64")}`;
- } catch (e) {
- console.error(`[icon] brand.dev error for ${domain}:`, e);
- return null;
+ const origin = new URL(request.url).origin;
+ const res = await fetch(`${origin}/icons/${id}.svg`);
+ if (res.ok) {
+ console.info(`[icon] serving static fallback for ${id}`);
+ return new Response(await res.text(), { headers: FALLBACK_HEADERS });
+ }
+ } catch {
+ // static file not available
}
+ return null;
}
-// ---------------------------------------------------------------------------
-// Route handler
-// ---------------------------------------------------------------------------
-
export async function GET(request: Request) {
const id = new URL(request.url).searchParams.get("id");
if (!id) return new Response("Missing id parameter", { status: 400 });
- // 1. Vercel Blob cache
- const cached = await blobGet(id);
- if (cached) return new Response(cached, { headers: SVG_HEADERS });
-
- // 2. Look up service → domain → brand.dev Logo Link
- const services = (discovery as unknown as { services: ServiceEntry[] })
- .services;
- const service = services.find((s) => s.id === id);
-
- if (service) {
- const domain = domainFor(service);
- if (domain) {
- const dataUri = await fetchLogo(domain);
- if (dataUri) {
- const svg = styledSvg(dataUri);
- await blobPut(id, svg);
- return new Response(svg, { headers: SVG_HEADERS });
- }
- }
+ // 1. Vercel Blob (primary source)
+ const blob = await blobGet(id);
+ if (blob) {
+ return new Response(blob.body, {
+ headers: { "Content-Type": blob.contentType, ...CACHE_HEADERS },
+ });
}
- // 3. Local dev fallback: try static /public/icons/ file
+ // 2. Static file fallback (public/icons/*.svg)
if (!BLOB_TOKEN) {
- try {
- const origin = new URL(request.url).origin;
- const res = await fetch(`${origin}/icons/${id}.svg`);
- if (res.ok)
- return new Response(await res.text(), { headers: SVG_HEADERS });
- } catch {
- // static file not found, fall through to letter fallback
- }
+ console.info(
+ `[icon] BLOB_READ_WRITE_TOKEN not set, using static fallback for ${id}`,
+ );
+ } else {
+ console.warn(`[icon] blob miss for ${id}, trying static fallback`);
}
+ const fallback = await staticFallback(request, id);
+ if (fallback) return fallback;
- // 4. Letter fallback (short CDN cache so brand.dev is retried later)
- return new Response(letterSvg(service?.name ?? id), {
- headers: FALLBACK_HEADERS,
- });
+ // 3. Letter SVG (guaranteed — never 404)
+ console.warn(`[icon] no icon found for ${id}, generating letter fallback`);
+ return new Response(letterSvg(id), { headers: FALLBACK_HEADERS });
}
diff --git a/src/pages/_root.css b/src/pages/_root.css
index c3306259..f2d3cbbf 100644
--- a/src/pages/_root.css
+++ b/src/pages/_root.css
@@ -382,9 +382,6 @@ a[data-v-sidebar-item]:not([data-active="true"]):not(:hover) {
rgba(255, 255, 255, 0.05)
);
- /* Invert service icons in dark mode (0 = none, 1 = full invert) */
- --icon-invert: light-dark(0, 1);
-
/* Inline code background — kept for reference, actual override is on the element */
--background-color-inline-code: light-dark(
var(--vocs-color-gray12),