From 6bd7866688daf743ee21597d97ac7a6248189e20 Mon Sep 17 00:00:00 2001 From: dysf888 <47450409+dysf888@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:10:19 +0800 Subject: [PATCH 1/2] Add simple-icons support for OAuth providers --- package-lock.json | 20 ++++++++ package.json | 1 + src/components/ui/icon.tsx | 96 ++++++++++++++++++++++++++++++++++++-- src/routes/login.tsx | 5 +- src/routes/profile.tsx | 10 +++- 5 files changed, 126 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2a9c7b0..d81cf71 100644 --- a/package-lock.json +++ b/package-lock.json @@ -50,6 +50,7 @@ "react-i18next": "^17.0.8", "react-router-dom": "^7.18.0", "react-virtuoso": "^4.18.7", + "simple-icons": "^16.24.1", "sonner": "^2.0.7", "swr": "^2.4.1", "tailwind-merge": "^3.6.0", @@ -5940,6 +5941,25 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-icons": { + "version": "16.24.1", + "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.24.1.tgz", + "integrity": "sha512-AnDQPrZAVzYSym7cBVIrnbhLk9auWGgkl+9hKvkbTqGEfH6TU7WKgumEXYYaJuM1Ib87+cnzr881UZniCM7t+A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/simple-icons" + }, + { + "type": "github", + "url": "https://github.com/sponsors/simple-icons" + } + ], + "license": "CC0-1.0", + "engines": { + "node": ">=0.12.18" + } + }, "node_modules/sonner": { "version": "2.0.7", "license": "MIT", diff --git a/package.json b/package.json index 52270f4..7b02dbf 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "react-i18next": "^17.0.8", "react-router-dom": "^7.18.0", "react-virtuoso": "^4.18.7", + "simple-icons": "^16.24.1", "sonner": "^2.0.7", "swr": "^2.4.1", "tailwind-merge": "^3.6.0", diff --git a/src/components/ui/icon.tsx b/src/components/ui/icon.tsx index 12445aa..4b299dd 100644 --- a/src/components/ui/icon.tsx +++ b/src/components/ui/icon.tsx @@ -1,7 +1,97 @@ -export function GitHubIcon(props: React.ComponentPropsWithoutRef<"svg">) { +import { useEffect, useState } from "react" + +type SVGProps = React.ComponentPropsWithoutRef<"svg"> + +const simpleIconModules = import.meta.glob("/node_modules/simple-icons/icons/*.svg", { + query: "?raw", + import: "default", +}) + +const iconLoadersBySlug = Object.fromEntries( + Object.entries(simpleIconModules).map(([path, loader]) => [ + path.slice(path.lastIndexOf("/") + 1, -".svg".length), + loader as () => Promise, + ]), +) + +const iconMarkupCache = new Map() + +function toProviderSlug(provider: string) { + return provider.trim().replace(/[^a-z0-9]+/gi, "").toLowerCase() +} + +function loadProviderIconMarkup(provider: string) { + const loader = iconLoadersBySlug[toProviderSlug(provider)] + return loader ? loader() : null +} + +function getSvgViewBox(markup: string) { + return markup.match(/viewBox="([^"]+)"/i)?.[1] ?? "0 0 24 24" +} + +function getSvgInnerMarkup(markup: string) { + return markup + .replace(/^]*>/i, "") + .replace(/<\/svg>\s*$/i, "") + .trim() +} + +export function OAuthProviderIcon({ + provider, + title, + ...props +}: SVGProps & { provider: string; title?: string }) { + const providerSlug = toProviderSlug(provider) + const [iconMarkup, setIconMarkup] = useState(() => { + return iconMarkupCache.get(providerSlug) ?? null + }) + + useEffect(() => { + const cached = iconMarkupCache.get(providerSlug) + if (cached !== undefined) { + setIconMarkup(cached) + return + } + + const loadPromise = loadProviderIconMarkup(provider) + if (!loadPromise) { + iconMarkupCache.set(providerSlug, null) + setIconMarkup(null) + return + } + + let cancelled = false + loadPromise + .then((markup) => { + if (cancelled) return + iconMarkupCache.set(providerSlug, markup) + setIconMarkup(markup) + }) + .catch(() => { + if (cancelled) return + iconMarkupCache.set(providerSlug, null) + setIconMarkup(null) + }) + + return () => { + cancelled = true + } + }, [provider, providerSlug]) + + if (!iconMarkup) { + return null + } + return ( - - + + {title ? {title} : null} + ) } diff --git a/src/routes/login.tsx b/src/routes/login.tsx index 6a48c01..b341429 100644 --- a/src/routes/login.tsx +++ b/src/routes/login.tsx @@ -8,7 +8,7 @@ import { FormLabel, FormMessage, } from "@/components/ui/form" -import { GitHubIcon } from "@/components/ui/icon" +import { OAuthProviderIcon } from "@/components/ui/icon" import { Input } from "@/components/ui/input" import { Separator } from "@/components/ui/separator" import { useAuth } from "@/hooks/useAuth" @@ -120,10 +120,11 @@ function Login() {
{settingData?.config?.oauth2_providers?.map((p: string) => ( ))} diff --git a/src/routes/profile.tsx b/src/routes/profile.tsx index 761f7af..b5e5506 100644 --- a/src/routes/profile.tsx +++ b/src/routes/profile.tsx @@ -4,6 +4,7 @@ import { ProfileCard } from "@/components/profile" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { OAuthProviderIcon } from "@/components/ui/icon" import { useMainStore } from "@/hooks/useMainStore" import { useMediaQuery } from "@/hooks/useMediaQuery" import { useServer } from "@/hooks/useServer" @@ -107,8 +108,15 @@ export default function ProfilePage() { {settingData?.config?.oauth2_providers?.map((provider) => ( -
+
+

{provider}:

{profile.oauth2_bind?.[provider.toLowerCase()] && (

From 64e6249e2d896fbfd891b11f5e6365c1a3f8336f Mon Sep 17 00:00:00 2001 From: dysf888 <47450409+dysf888@users.noreply.github.com> Date: Fri, 3 Jul 2026 11:20:21 +0800 Subject: [PATCH 2/2] Harden OAuth provider icon loading --- src/components/ui/icon.tsx | 81 +++++++++++++++++++++++--------------- 1 file changed, 50 insertions(+), 31 deletions(-) diff --git a/src/components/ui/icon.tsx b/src/components/ui/icon.tsx index 4b299dd..1ef9e0b 100644 --- a/src/components/ui/icon.tsx +++ b/src/components/ui/icon.tsx @@ -1,6 +1,15 @@ import { useEffect, useState } from "react" type SVGProps = React.ComponentPropsWithoutRef<"svg"> +type IconMarkupState = + | { status: "ready"; icon: ParsedIconMarkup } + | { status: "missing" } + +type ParsedIconMarkup = { + title?: string + viewBox: string + path: string +} const simpleIconModules = import.meta.glob("/node_modules/simple-icons/icons/*.svg", { query: "?raw", @@ -14,7 +23,7 @@ const iconLoadersBySlug = Object.fromEntries( ]), ) -const iconMarkupCache = new Map() +const iconMarkupCache = new Map() function toProviderSlug(provider: string) { return provider.trim().replace(/[^a-z0-9]+/gi, "").toLowerCase() @@ -25,15 +34,30 @@ function loadProviderIconMarkup(provider: string) { return loader ? loader() : null } -function getSvgViewBox(markup: string) { - return markup.match(/viewBox="([^"]+)"/i)?.[1] ?? "0 0 24 24" +function parseIconMarkup(markup: string): ParsedIconMarkup | null { + const viewBox = markup.match(/viewBox="([^"]+)"/i)?.[1] ?? "0 0 24 24" + const title = markup.match(/([^<]*)<\/title>/i)?.[1] + const path = markup.match(/<path d="([^"]+)"/i)?.[1] + + if (!path) { + return null + } + + return { title, viewBox, path } } -function getSvgInnerMarkup(markup: string) { - return markup - .replace(/^<svg[^>]*>/i, "") - .replace(/<\/svg>\s*$/i, "") - .trim() +async function loadProviderIcon(provider: string): Promise<IconMarkupState> { + const markup = await loadProviderIconMarkup(provider) + if (!markup) { + return { status: "missing" } + } + + const parsed = parseIconMarkup(markup) + if (!parsed) { + return { status: "missing" } + } + + return { status: "ready", icon: parsed } } export function OAuthProviderIcon({ @@ -42,35 +66,28 @@ export function OAuthProviderIcon({ ...props }: SVGProps & { provider: string; title?: string }) { const providerSlug = toProviderSlug(provider) - const [iconMarkup, setIconMarkup] = useState<string | null>(() => { + const [iconState, setIconState] = useState<IconMarkupState | null>(() => { return iconMarkupCache.get(providerSlug) ?? null }) useEffect(() => { - const cached = iconMarkupCache.get(providerSlug) - if (cached !== undefined) { - setIconMarkup(cached) - return - } - - const loadPromise = loadProviderIconMarkup(provider) - if (!loadPromise) { - iconMarkupCache.set(providerSlug, null) - setIconMarkup(null) + if (iconMarkupCache.has(providerSlug)) { + setIconState(iconMarkupCache.get(providerSlug) ?? null) return } let cancelled = false - loadPromise - .then((markup) => { + loadProviderIcon(provider) + .then((result) => { if (cancelled) return - iconMarkupCache.set(providerSlug, markup) - setIconMarkup(markup) + iconMarkupCache.set(providerSlug, result) + setIconState(result) }) .catch(() => { if (cancelled) return - iconMarkupCache.set(providerSlug, null) - setIconMarkup(null) + const missingState = { status: "missing" } as const + iconMarkupCache.set(providerSlug, missingState) + setIconState(missingState) }) return () => { @@ -78,20 +95,22 @@ export function OAuthProviderIcon({ } }, [provider, providerSlug]) - if (!iconMarkup) { + if (!iconState || iconState.status !== "ready") { return null } + const iconTitle = title ?? iconState.icon.title + return ( <svg - viewBox={getSvgViewBox(iconMarkup)} + viewBox={iconState.icon.viewBox} fill="currentColor" - aria-hidden={title ? undefined : "true"} - role={title ? "img" : undefined} + aria-hidden={iconTitle ? undefined : "true"} + role={iconTitle ? "img" : undefined} {...props} > - {title ? <title>{title} : null} - + {iconTitle ? {iconTitle} : null} + ) }