GithubRepos: Support User Orgs

This commit is contained in:
thororen1234 2026-07-28 00:00:24 -04:00
parent 88244a36cc
commit faf2f66616
No known key found for this signature in database
11 changed files with 593 additions and 437 deletions

View file

@ -4,64 +4,64 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
export function getLanguageColor(language: string): string {
const colors: Record<string, string> = {
"JavaScript": "#f1e05a",
"TypeScript": "#3178c6",
"Python": "#3572A5",
"Java": "#b07219",
"C#": "#178600",
"C++": "#f34b7d",
"C": "#555555",
"HTML": "#e34c26",
"CSS": "#563d7c",
"PHP": "#4F5D95",
"Ruby": "#701516",
"Go": "#00ADD8",
"Rust": "#dea584",
"Swift": "#ffac45",
"Kotlin": "#A97BFF",
"Dart": "#00B4AB",
"Shell": "#89e051",
"PowerShell": "#012456",
"Lua": "#000080",
"Perl": "#0298c3",
"R": "#198CE7",
"Scala": "#c22d40",
"Haskell": "#5e5086",
"Elixir": "#6e4a7e",
"Clojure": "#db5855",
"Vue": "#41b883",
"Svelte": "#ff3e00",
"Jupyter Notebook": "#DA5B0B",
"Assembly": "#6E4C13",
"COBOL": "#004B85",
"CoffeeScript": "#244776",
"Crystal": "#000100",
"D": "#BA595E",
"F#": "#B845FC",
"Fortran": "#4d41b1",
"GLSL": "#5686A5",
"Groovy": "#e69f56",
"Julia": "#a270ba",
"Markdown": "#083fa1",
"MATLAB": "#bb92ac",
"Objective-C": "#438eff",
"OCaml": "#3be133",
"Pascal": "#E3F171",
"Prolog": "#74283c",
"PureScript": "#1D222D",
"Racket": "#3c5caa",
"Raku": "#0000fb",
"Reason": "#ff5847",
"SCSS": "#c6538c",
"Solidity": "#AA6746",
"Tcl": "#e4cc98",
"Verilog": "#b2b7f8",
"VHDL": "#adb2cb",
"WebAssembly": "#04133b",
"Zig": "#ec915c"
};
const colors: Record<string, string> = {
"JavaScript": "#f1e05a",
"TypeScript": "#3178c6",
"Python": "#3572A5",
"Java": "#b07219",
"C#": "#178600",
"C++": "#f34b7d",
"C": "#555555",
"HTML": "#e34c26",
"CSS": "#563d7c",
"PHP": "#4F5D95",
"Ruby": "#701516",
"Go": "#00ADD8",
"Rust": "#dea584",
"Swift": "#ffac45",
"Kotlin": "#A97BFF",
"Dart": "#00B4AB",
"Shell": "#89e051",
"PowerShell": "#012456",
"Lua": "#000080",
"Perl": "#0298c3",
"R": "#198CE7",
"Scala": "#c22d40",
"Haskell": "#5e5086",
"Elixir": "#6e4a7e",
"Clojure": "#db5855",
"Vue": "#41b883",
"Svelte": "#ff3e00",
"Jupyter Notebook": "#DA5B0B",
"Assembly": "#6E4C13",
"COBOL": "#004B85",
"CoffeeScript": "#244776",
"Crystal": "#000100",
"D": "#BA595E",
"F#": "#B845FC",
"Fortran": "#4d41b1",
"GLSL": "#5686A5",
"Groovy": "#e69f56",
"Julia": "#a270ba",
"Markdown": "#083fa1",
"MATLAB": "#bb92ac",
"Objective-C": "#438eff",
"OCaml": "#3be133",
"Pascal": "#E3F171",
"Prolog": "#74283c",
"PureScript": "#1D222D",
"Racket": "#3c5caa",
"Raku": "#0000fb",
"Reason": "#ff5847",
"SCSS": "#c6538c",
"Solidity": "#AA6746",
"Tcl": "#e4cc98",
"Verilog": "#b2b7f8",
"VHDL": "#adb2cb",
"WebAssembly": "#04133b",
"Zig": "#ec915c"
};
export function getLanguageColor(language: string): string {
return colors[language] || "#858585";
}

View file

@ -6,11 +6,12 @@
import { Paragraph } from "@components/Paragraph";
import { Span } from "@components/Span";
import { fetchReposByUserId, fetchReposByUsername, fetchUserInfo, GitHubUserInfo } from "@equicordplugins/githubRepos/githubApi";
import { GitHubRepo } from "@equicordplugins/githubRepos/types";
import { fetchOrgRepos, fetchReposByUserId, fetchReposByUsername, fetchUserInfo, fetchUserOrgs, GitHubUserInfo } from "@equicordplugins/githubRepos/githubApi";
import { GitHubRepo, RepoGroup } from "@equicordplugins/githubRepos/types";
import { buildRepoGroups, getLanguageIconUrl, PERSONAL_GROUP_KEY } from "@equicordplugins/githubRepos/utils";
import { classes } from "@utils/misc";
import { findCssClassesLazy } from "@webpack";
import { Clickable, openModal,React, useEffect, UserProfileStore, useState } from "@webpack/common";
import { Clickable, openModal, React, useEffect, UserProfileStore, useState } from "@webpack/common";
import { ReposModal } from "./ReposModal";
@ -19,39 +20,23 @@ const ProfileCardClasses = findCssClassesLazy("cardsList", "firstCardContainer",
const ProfileCardContainerClasses = findCssClassesLazy("innerContainer", "icons", "icon", "displayCount", "displayCountText", "displayCountTextColor", "breadcrumb");
const ProfileCardOverlayClasses = findCssClassesLazy("overlay", "isPrivate", "outer");
const LANGUAGE_MAP: Record<string, string> = {
"c++": "cplusplus",
"c#": "csharp",
"f#": "fsharp",
"q#": "qsharp",
"objective-c": "objectivec",
"visual basic": "visualbasic",
"shell": "bash",
"batchfile": "bash",
"vim script": "vim",
"dockerfile": "docker",
"gdscript": "godot",
"html": "html5",
};
function getLanguageIconUrl(language: string | null): string {
if (!language) return "https://cdn.jsdelivr.net/gh/devicons/devicon@develop/icons/github/github-original.svg";
const normalized = LANGUAGE_MAP[language.toLowerCase()] ?? language.toLowerCase().replace(/\s+/g, "");
return `https://cdn.jsdelivr.net/gh/devicons/devicon@develop/icons/${normalized}/${normalized}-original.svg`;
}
export function ProfilePopoutComponent({ id, isSideBar = false }: { id: string, isSideBar?: boolean; }) {
const [repos, setRepos] = useState<GitHubRepo[]>([]);
const [groups, setGroups] = useState<RepoGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [userInfo, setUserInfo] = useState<GitHubUserInfo | null>(null);
const openReposModal = () => {
if (!userInfo) return;
const sortedRepos = [...repos].sort((a, b) => b.stargazers_count - a.stargazers_count);
openModal(props => (
<ReposModal repos={sortedRepos} username={userInfo.username} rootProps={props} />
<ReposModal
groups={groups.length ? groups : [{ key: PERSONAL_GROUP_KEY, label: userInfo?.username ?? "Personal", avatarUrl: userInfo?.avatarUrl, repos }]}
initialActiveKey={PERSONAL_GROUP_KEY}
username={userInfo.username}
rootProps={props}
/>
));
};
@ -73,12 +58,17 @@ export function ProfilePopoutComponent({ id, isSideBar = false }: { id: string,
const githubId = githubConnection.id;
const reposById = await fetchReposByUserId(githubId);
if (reposById) { setRepos(reposById); setLoading(false); return; }
let personalRepos = await fetchReposByUserId(githubId);
if (!personalRepos) personalRepos = await fetchReposByUsername(username);
const reposByUsername = await fetchReposByUsername(username);
setRepos(reposByUsername);
setRepos(personalRepos);
setLoading(false);
const orgs = await fetchUserOrgs(username);
const orgReposEntries = await Promise.all(orgs.map(async org => [org.login, await fetchOrgRepos(org.login)]));
const orgRepos = Object.fromEntries(orgReposEntries);
setGroups(buildRepoGroups(userInfoData?.username ?? username, personalRepos, orgs, orgRepos, userInfoData?.avatarUrl));
} catch (error) {
setError(error instanceof Error ? error.message : "Failed to fetch repositories");
setLoading(false);

View file

@ -5,32 +5,24 @@
*/
import { BaseText } from "@components/BaseText";
import { fetchReposByUserId, fetchReposByUsername, fetchUserInfo, GitHubUserInfo } from "@equicordplugins/githubRepos/githubApi";
import { GitHubRepo } from "@equicordplugins/githubRepos/types";
import { openModal,React, useEffect, UserProfileStore, useState } from "@webpack/common";
import { fetchOrgRepos, fetchReposByUserId, fetchReposByUsername, fetchUserInfo, fetchUserOrgs } from "@equicordplugins/githubRepos/githubApi";
import { GitHubRepo, RepoGroup, RepoSortMode } from "@equicordplugins/githubRepos/types";
import { buildRepoGroups, PERSONAL_GROUP_KEY, sortGroups } from "@equicordplugins/githubRepos/utils";
import { React, useEffect, UserProfileStore, useState } from "@webpack/common";
import { cl, settings } from "..";
import { RepoCard } from "./RepoCard";
import { ReposModal } from "./ReposModal";
import { RepoSubTabs } from "./RepoSubTabs";
export function ProfileTabComponent({ id }: { id: string, theme: string; }) {
const [repos, setRepos] = useState<GitHubRepo[]>([]);
const [groups, setGroups] = useState<RepoGroup[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [userInfo, setUserInfo] = useState<GitHubUserInfo | null>(null);
const [activeKey, setActiveKey] = useState<string>(PERSONAL_GROUP_KEY);
const [sortMode, setSortMode] = useState<RepoSortMode>("count");
const openReposModal = () => {
if (!userInfo) return;
const sortedRepos = [...repos].sort((a, b) => b.stargazers_count - a.stargazers_count);
openModal(props => (
<ReposModal
repos={sortedRepos}
username={userInfo.username}
rootProps={props}
/>
));
};
const sortedGroups = sortGroups(groups, sortMode);
const activeGroup = sortedGroups.find(g => g.key === activeKey) ?? sortedGroups[0];
useEffect(() => {
const fetchData = async () => {
@ -55,22 +47,19 @@ export function ProfileTabComponent({ id }: { id: string, theme: string; }) {
const username = githubConnection.name;
const userInfoData = await fetchUserInfo(username);
if (userInfoData) {
setUserInfo(userInfoData);
}
const githubId = githubConnection.id;
// Try to fetch by ID first, fall back to username
const reposById = await fetchReposByUserId(githubId);
if (reposById) {
setRepos(reposById);
setLoading(false);
return;
}
let personalRepos: GitHubRepo[] | null = await fetchReposByUserId(githubId);
if (!personalRepos) personalRepos = await fetchReposByUsername(username);
const reposByUsername = await fetchReposByUsername(username);
setRepos(reposByUsername);
const orgs = await fetchUserOrgs(username);
const orgReposEntries = await Promise.all(orgs.map(async org => [org.login, await fetchOrgRepos(org.login)]));
const orgRepos = Object.fromEntries(orgReposEntries);
const builtGroups = buildRepoGroups(userInfoData?.username ?? username, personalRepos ?? [], orgs, orgRepos, userInfoData?.avatarUrl);
setGroups(builtGroups);
setActiveKey(builtGroups[0]?.key ?? PERSONAL_GROUP_KEY);
setLoading(false);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Failed to fetch repositories";
@ -92,20 +81,20 @@ export function ProfileTabComponent({ id }: { id: string, theme: string; }) {
Error: {error}
</BaseText>;
if (!repos.length) return null;
if (!groups.length) return null;
return (
<div className={cl("container", "tab")}>
<BaseText size="xs" weight="semibold" className={cl("header")}>
GitHub Repositories
{userInfo && (
<span className={cl("count")}>
{` (${repos.length})`}
</span>
)}
</BaseText>
<RepoSubTabs
groups={sortedGroups}
activeKey={activeGroup?.key ?? PERSONAL_GROUP_KEY}
onSelect={setActiveKey}
sortMode={sortMode}
onToggleSort={() => setSortMode(mode => mode === "count" ? "alpha" : "count")}
canSort={sortedGroups.length > 2}
/>
<div className={cl("list")}>
{repos.map(repo => (
{activeGroup?.repos.map(repo => (
<RepoCard
key={repo.id}
repo={repo}

View file

@ -7,92 +7,62 @@
import { BaseText } from "@components/BaseText";
import { getLanguageColor } from "@equicordplugins/githubRepos/colors";
import { RepoCardProps } from "@equicordplugins/githubRepos/types";
import { React, Tooltip } from "@webpack/common";
import { getLanguageIconUrl } from "@equicordplugins/githubRepos/utils";
import { React } from "@webpack/common";
import { cl } from "..";
import { Star } from "./Star";
export function RepoCard({ repo, showStars, showLanguage }: RepoCardProps) {
const handleClick = () => window.open(repo.html_url, "_blank");
const renderStars = () => {
if (!showStars) return null;
return (
<div className={cl("stars")}>
<Star className={cl("star-icon")} />
<BaseText size="sm">{repo.stargazers_count.toLocaleString()}</BaseText>
</div>
);
};
const renderLink = () => {
return (
<div onClick={handleClick}>
<svg
className={cl("link")}
aria-hidden="true"
role="img"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
>
<path
d="M8 5a1 1 0 0 0 0 2h7.59L5.29 17.3a1 1 0 1 0 1.42 1.4L17 8.42V16a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1H8Z"
/>
</svg>
</div>
);
};
const renderLanguage = () => {
if (!showLanguage || !repo.language) return null;
return (
<div className={cl("language")}>
<span
className={cl("language-color")}
style={{ backgroundColor: getLanguageColor(repo.language) }}
/>
<BaseText size="sm" className={cl("lang-name")}>{repo.language}</BaseText>
{renderStars()}
</div >
);
};
const langColor = getLanguageColor(repo?.language);
return (
<>
{repo.description ? (
<Tooltip text={repo.description} key={repo.id}>
{({ onMouseLeave, onMouseEnter }) => (
<div className={cl("card")}
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
onClick={handleClick}
>
<div className={cl("header")}>
<BaseText size="sm" weight="medium" className={cl("name")}>
{repo.name}
</BaseText>
{renderLink()}
</div>
{renderLanguage()}
</div>
)}
</Tooltip>
) : (
<div className={cl("card")} onClick={handleClick}>
<div className={cl("header")}>
<BaseText size="sm" weight="medium" className={cl("name")} >
{repo.name}
</BaseText>
{renderLink()}
<div className={cl("card")} onClick={handleClick}>
<div className={cl("art")} style={{ backgroundColor: `${langColor}2b` }}>
<img className={cl("art-icon")} src={getLanguageIconUrl(repo.language)} alt="" />
</div>
<div className={cl("info")}>
<BaseText size="sm" weight="semibold" className={cl("name")}>
{repo.name}
</BaseText>
{repo.description && (
<BaseText size="sm" className={cl("description")}>
{repo.description}
</BaseText>
)}
{(showLanguage || showStars) && (
<div className={cl("meta")}>
{showLanguage && repo.language && (
<span className={cl("language")}>
<span className={cl("language-color")} style={{ backgroundColor: langColor }} />
{repo.language}
</span>
)}
{showStars && (
<span className={cl("stars")}>
<Star className={cl("star-icon")} width={12} height={12} />
{repo.stargazers_count.toLocaleString()}
</span>
)}
</div>
{renderLanguage()}
</div>
)}
</>
)}
</div>
<svg
className={cl("link")}
aria-hidden="true"
role="img"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
fill="none"
viewBox="0 0 24 24"
>
<path
fill="currentColor"
d="M8 5a1 1 0 0 0 0 2h7.59L5.29 17.3a1 1 0 1 0 1.42 1.4L17 8.42V16a1 1 0 1 0 2 0V6a1 1 0 0 0-1-1H8Z"
/>
</svg>
</div>
);
}

View file

@ -0,0 +1,60 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { RepoGroup, RepoSortMode } from "@equicordplugins/githubRepos/types";
import { classes } from "@utils/misc";
import { React } from "@webpack/common";
import { cl } from "..";
import { SortIcon } from "./SortIcon";
interface RepoSubTabsProps {
groups: RepoGroup[];
activeKey: string;
onSelect: (key: string) => void;
sortMode: RepoSortMode;
onToggleSort: () => void;
canSort: boolean;
}
export function RepoSubTabs({ groups, activeKey, onSelect, sortMode, onToggleSort, canSort }: RepoSubTabsProps) {
if (groups.length <= 1 && !canSort) return null;
const handleWheel = (e: React.WheelEvent<HTMLDivElement>) => {
if (e.deltaY === 0) return;
e.currentTarget.scrollLeft += e.deltaY;
};
return (
<div className={cl("subtabs")}>
<div className={cl("subtabs-list")} onWheel={handleWheel}>
{groups.map(group => (
<div
key={group.key}
className={classes(cl("subtab"), activeKey === group.key && cl("subtab-active"))}
onClick={() => onSelect(group.key)}
>
{group.avatarUrl && (
<img className={cl("subtab-avatar")} src={group.avatarUrl} alt="" />
)}
<span className={cl("subtab-label")}>{group.label}</span>
<span className={cl("subtab-count")}>{group.repos.length}</span>
</div>
))}
</div>
{canSort && (
<div
className={cl("sort-toggle")}
onClick={onToggleSort}
title={sortMode === "count" ? "Sorted by repo count. Click to sort A-Z" : "Sorted A-Z. Click to sort by repo count"}
>
<SortIcon className={cl("sort-icon")} width={14} height={14} />
<span>{sortMode === "count" ? "Most repos" : "A-Z"}</span>
</div>
)}
</div>
);
}

View file

@ -4,61 +4,28 @@
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { getLanguageColor } from "@equicordplugins/githubRepos/colors";
import { GitHubRepo } from "@equicordplugins/githubRepos/types";
import { RepoGroup, RepoSortMode } from "@equicordplugins/githubRepos/types";
import { PERSONAL_GROUP_KEY, sortGroups } from "@equicordplugins/githubRepos/utils";
import { RenderModalProps } from "@vencord/discord-types";
import { Modal, React } from "@webpack/common";
import { Modal, React, useState } from "@webpack/common";
import { cl } from "..";
import { Star } from "./Star";
import { cl, settings } from "..";
import { RepoCard } from "./RepoCard";
import { RepoSubTabs } from "./RepoSubTabs";
interface ReposModalProps {
repos: GitHubRepo[];
groups: RepoGroup[];
initialActiveKey: string;
username: string;
rootProps: any;
}
export function ReposModal({ repos, username, rootProps }: ReposModalProps & { rootProps: RenderModalProps; }) {
const renderTableHeader = () => (
<thead>
<tr>
<th>Repository</th>
<th>Description</th>
<th>Language</th>
<th>Stars</th>
</tr>
</thead>
);
export function ReposModal({ groups, initialActiveKey, username, rootProps }: ReposModalProps & { rootProps: RenderModalProps; }) {
const [activeKey, setActiveKey] = useState(initialActiveKey);
const [sortMode, setSortMode] = useState<RepoSortMode>("count");
const renderTableRow = (repo: GitHubRepo) => (
<tr key={repo.id} onClick={() => window.open(repo.html_url, "_blank")}>
<td>
<div className={cl("table-name")}>{repo.name}</div>
</td>
<td>
<div className={cl("table-description")}>
{repo.description || ""}
</div>
</td>
<td>
{repo.language && (
<div className={cl("table-language")}>
<span
className={cl("table-language-color")}
style={{ backgroundColor: getLanguageColor(repo.language) }}
/>
<span>{repo.language}</span>
</div>
)}
</td>
<td>
<div className={cl("table-stars")}>
<Star className={cl("star-icon")} />
<span>{repo.stargazers_count.toLocaleString()}</span>
</div>
</td>
</tr>
);
const sortedGroups = sortGroups(groups, sortMode);
const activeGroup = sortedGroups.find(g => g.key === activeKey) ?? sortedGroups[0];
return (
<Modal
@ -79,19 +46,25 @@ export function ReposModal({ repos, username, rootProps }: ReposModalProps & { r
}
]}
>
<div className={cl("table-container")}>
<table className={cl("table")}>
<colgroup>
<col className={cl("header-repo")} />
<col className={cl("header-description")} />
<col className={cl("header-language")} />
<col className={cl("header-stars")} />
</colgroup>
{renderTableHeader()}
<tbody>
{repos.map(renderTableRow)}
</tbody>
</table>
<div className={cl("modal-content")}>
<RepoSubTabs
groups={sortedGroups}
activeKey={activeGroup?.key ?? PERSONAL_GROUP_KEY}
onSelect={setActiveKey}
sortMode={sortMode}
onToggleSort={() => setSortMode(mode => mode === "count" ? "alpha" : "count")}
canSort={sortedGroups.length > 2}
/>
<div className={cl("list", "modal-list")}>
{activeGroup?.repos.map(repo => (
<RepoCard
key={repo.id}
repo={repo}
showStars={settings.store.showStars}
showLanguage={settings.store.showLanguage}
/>
))}
</div>
</div>
</Modal>
);

View file

@ -0,0 +1,26 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { IconProps } from "@equicordplugins/githubRepos/types";
import { React } from "@webpack/common";
export function SortIcon({ className, width = 16, height = 16 }: IconProps) {
return (
<svg
className={className}
width={width}
height={height}
viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
fill="none"
>
<path
fill="currentColor"
d="M4.5 2a.75.75 0 0 1 .75.75v8.69l1.72-1.72a.75.75 0 1 1 1.06 1.06l-3 3a.75.75 0 0 1-1.06 0l-3-3a.75.75 0 1 1 1.06-1.06l1.72 1.72V2.75A.75.75 0 0 1 4.5 2ZM11.5 14a.75.75 0 0 1-.75-.75v-8.69l-1.72 1.72a.75.75 0 1 1-1.06-1.06l3-3a.75.75 0 0 1 1.06 0l3 3a.75.75 0 1 1-1.06 1.06l-1.72-1.72v8.69a.75.75 0 0 1-.75.75Z"
/>
</svg>
);
}

View file

@ -6,13 +6,14 @@
import { Logger } from "@utils/Logger";
import { GitHubRepo } from "./types";
import { GitHubOrg, GitHubRepo } from "./types";
const logger = new Logger("GitHubRepos");
export interface GitHubUserInfo {
username: string;
totalRepos: number;
avatarUrl: string;
}
export async function fetchUserInfo(username: string): Promise<GitHubUserInfo | null> {
@ -25,7 +26,8 @@ export async function fetchUserInfo(username: string): Promise<GitHubUserInfo |
const userData = await userInfoResponse.json();
return {
username: userData.login,
totalRepos: userData.public_repos
totalRepos: userData.public_repos,
avatarUrl: userData.avatar_url
};
} catch (error) {
logger.error("Error fetching user info", error);
@ -60,6 +62,35 @@ export async function fetchReposByUsername(username: string, perPage: number = 3
return sortReposByStars(data);
}
export async function fetchUserOrgs(username: string): Promise<GitHubOrg[]> {
try {
const apiUrl = `https://api.github.com/users/${username}/orgs`;
const response = await fetch(apiUrl);
if (!response.ok) return [];
return await response.json();
} catch (error) {
logger.error("Error fetching user orgs", error);
return [];
}
}
export async function fetchOrgRepos(org: string, perPage: number = 30): Promise<GitHubRepo[]> {
try {
const apiUrl = `https://api.github.com/orgs/${org}/repos?sort=stars&direction=desc&per_page=${perPage}`;
const response = await fetch(apiUrl);
if (!response.ok) return [];
const data = await response.json();
return sortReposByStars(data);
} catch (error) {
logger.error("Error fetching org repos", error);
return [];
}
}
function sortReposByStars(repos: GitHubRepo[]): GitHubRepo[] {
return repos.sort((a, b) => b.stargazers_count - a.stargazers_count);
}

View file

@ -4,19 +4,20 @@
gap: 8px;
}
.vc-github-repos-header {
color: var(--text-default);
}
.vc-github-repos-list {
display: grid;
grid-template-columns: 260px 260px;
gap: 16px;
display: flex;
flex-direction: column;
gap: 8px;
}
.vc-github-repos-star-icon {
color: var(--text-status-idle);
fill: var(--text-status-idle);
}
.vc-github-repos-lang-name {
font-size: 12px;
flex-shrink: 0;
}
.vc-github-repos-footer {
@ -32,26 +33,169 @@
);
}
.vc-github-repos-button,
.vc-github-repos-show-more {
color: var(--text-default);
width: 100%;
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
cursor: pointer;
}
.vc-github-repos-card {
padding: 8px;
border-radius: var(--radius-xs);
border: 1px solid
.vc-github-repos-show-more:hover {
color: var(--text-default);
text-decoration: underline;
}
/* Sub-tabs */
.vc-github-repos-subtabs {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
min-width: 0;
border-bottom: 1px solid
var(
--profile-body-divider-color,
var(--input-border-default, var(--input-border))
);
padding-bottom: 6px;
}
.vc-github-repos-header {
color: var(--text-default);
.vc-github-repos-subtabs-list {
display: flex;
justify-content: space-between;
align-items: center;
gap: 4px;
overflow-x: auto;
flex: 0 1 auto;
min-width: 0;
scrollbar-width: none;
}
.vc-github-repos-subtabs-list::-webkit-scrollbar {
display: none;
}
.vc-github-repos-subtab {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
border-radius: var(--radius-round);
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
transition: background-color 0.15s ease, color 0.15s ease;
}
.vc-github-repos-subtab:hover {
background-color: var(--background-mod-subtle);
color: var(--text-default);
}
.vc-github-repos-subtab-active,
.vc-github-repos-subtab-active:hover {
background-color: var(--background-mod-strong);
color: var(--text-default);
}
.vc-github-repos-subtab-avatar {
width: 14px;
height: 14px;
border-radius: var(--radius-round);
flex-shrink: 0;
}
.vc-github-repos-subtab-count {
color: var(--text-faint);
}
.vc-github-repos-sort-toggle {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
border-radius: var(--radius-xs);
color: var(--text-muted);
font-size: 12px;
font-weight: 500;
white-space: nowrap;
cursor: pointer;
flex-shrink: 0;
transition: background-color 0.15s ease, color 0.15s ease;
}
.vc-github-repos-sort-toggle:hover {
background-color: var(--background-mod-subtle);
color: var(--text-default);
}
.vc-github-repos-sort-icon {
flex-shrink: 0;
}
/* Repo card (song-spotlight style) */
.vc-github-repos-card {
display: flex;
align-items: center;
gap: 10px;
padding: 6px 8px;
border-radius: var(--radius-sm);
cursor: pointer;
position: relative;
transition: background-color 0.15s ease;
}
.vc-github-repos-card:hover {
background-color: var(--background-mod-subtle);
}
.vc-github-repos-art {
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.vc-github-repos-art-icon {
width: 20px;
height: 20px;
object-fit: contain;
}
.vc-github-repos-info {
display: flex;
flex-direction: column;
min-width: 0;
flex: 1;
gap: 1px;
}
.vc-github-repos-name {
color: var(--text-strong);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vc-github-repos-description {
color: var(--text-muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vc-github-repos-meta {
display: flex;
align-items: center;
gap: 10px;
margin-top: 2px;
}
.vc-github-repos-stars {
@ -59,14 +203,15 @@
align-items: center;
gap: 4px;
color: var(--text-muted);
font-size: 11px;
}
.vc-github-repos-language {
display: flex;
align-items: center;
gap: 6px;
gap: 5px;
color: var(--text-muted);
font-size: 12px;
font-size: 11px;
}
.vc-github-repos-language-color {
@ -76,119 +221,42 @@
display: inline-block;
}
.vc-github-repos-link {
cursor: pointer;
fill: var(--interactive-icon-default);
flex-shrink: 0;
opacity: 0;
transition: opacity 0.15s ease;
}
.vc-github-repos-link:hover {
fill: var(--interactive-icon-hover);
}
.vc-github-repos-card:hover .vc-github-repos-link {
opacity: 1;
}
/* Modal */
.vc-github-repos-modal {
width: 100%;
max-width: 900px;
background-color: var(--background-base-lower);
}
.vc-github-repos-modal-title {
color: var(--text-strong);
font-size: 20px;
font-weight: 600;
}
.vc-github-repos-modal-content {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px 0;
overflow: hidden;
}
.vc-github-repos-table-container {
width: 100%;
overflow-x: auto;
}
.vc-github-repos-table {
width: 100%;
border-collapse: collapse;
color: var(--text-default);
font-size: 14px;
table-layout: fixed;
}
.vc-github-repos-table colgroup {
display: table-column-group;
}
.vc-github-repos-table col {
display: table-column;
}
.vc-github-repos-table thead {
border-bottom: 2px solid var(--input-border-default, var(--input-border));
}
.vc-github-repos-table th {
text-align: left;
padding: 10px 16px;
font-weight: 600;
color: var(--text-strong);
border-bottom: 2px solid var(--input-border-default, var(--input-border));
}
.vc-github-repos-table th:last-child {
text-align: center;
}
.vc-github-repos-table tbody tr {
border-bottom: 1px solid var(--input-border-default, var(--input-border));
cursor: pointer;
transition: background-color 0.2s ease;
}
.vc-github-repos-table tbody tr:hover {
background-color: var(--background-mod-subtle);
}
.vc-github-repos-table td {
padding: 12px 16px;
vertical-align: middle;
border-bottom: 1px solid var(--input-border-default, var(--input-border));
overflow: hidden;
text-overflow: ellipsis;
}
.vc-github-repos-table-name {
font-weight: 500;
color: var(--text-strong);
display: flex;
align-items: center;
gap: 6px;
}
.vc-github-repos-table-fork-icon {
color: var(--text-muted);
}
.vc-github-repos-table-description {
color: var(--text-default);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vc-github-repos-table-language {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.vc-github-repos-table-language-color {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
flex-shrink: 0;
}
.vc-github-repos-table-stars {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
white-space: nowrap;
text-align: center;
.vc-github-repos-modal-list {
max-height: 60vh;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thin-thumb) var(--scrollbar-thin-track);
}
.vc-github-repos-modal-footer-close {
@ -216,40 +284,11 @@
margin-top: 10px;
}
.vc-github-repos-count {
color: var(--text-muted);
}
.vc-github-repos-error {
color: var(--text-feedback-critical);
margin-top: 10px;
}
.vc-github-repos-link {
cursor: pointer;
fill: var(--interactive-icon-default);
}
.vc-github-repos-name {
color: var(--interactive-icon-active);
}
.vc-github-repos-header-repo {
width: 20%;
}
.vc-github-repos-header-description {
width: 50%;
}
.vc-github-repos-header-language {
width: 15%;
}
.vc-github-repos-header-stars {
width: 10%;
}
/* Tab variant styles */
.vc-github-repos-container.vc-github-repos-tab {
@ -259,15 +298,13 @@
width: 100%;
box-sizing: border-box;
display: grid;
grid-template-rows: auto 1fr;
gap: 4px;
grid-template-rows: auto auto 1fr auto;
gap: 6px;
overflow: hidden;
position: relative;
}
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 4px;
overflow: hidden scroll;
scrollbar-width: thin;
scrollbar-color: var(--scrollbar-thin-thumb) var(--scrollbar-thin-track);
@ -276,27 +313,28 @@
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-card {
padding: 4px 6px;
cursor: pointer;
}
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-language-color {
width: 8px;
height: 8px;
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-art {
width: 32px;
height: 32px;
}
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-art-icon {
width: 16px;
height: 16px;
}
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-name {
font-size: 14px;
line-height: 1.1;
font-size: 13px;
line-height: 1.2;
}
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-description {
font-size: 11px;
}
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-language,
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-stars {
font-size: 11px;
line-height: 1.1;
gap: 3px;
}
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-star-icon,
.vc-github-repos-container.vc-github-repos-tab .vc-github-repos-stars {
margin-left: 2px;
font-size: 10px;
}

View file

@ -12,8 +12,26 @@ export interface GitHubRepo {
stargazers_count: number;
language: string;
fork: boolean;
owner: {
login: string;
type: string;
};
}
export interface GitHubOrg {
login: string;
avatar_url: string;
}
export interface RepoGroup {
key: string;
label: string;
avatarUrl?: string;
repos: GitHubRepo[];
}
export type RepoSortMode = "count" | "alpha";
export interface IconProps {
className?: string;
width?: number;

View file

@ -0,0 +1,61 @@
/*
* Vencord, a Discord client mod
* Copyright (c) 2026 Vendicated and contributors
* SPDX-License-Identifier: GPL-3.0-or-later
*/
import { GitHubOrg, GitHubRepo, RepoGroup, RepoSortMode } from "./types";
const LANGUAGE_ICON_MAP: Record<string, string> = {
"c++": "cplusplus",
"c#": "csharp",
"f#": "fsharp",
"q#": "qsharp",
"objective-c": "objectivec",
"visual basic": "visualbasic",
"shell": "bash",
"batchfile": "bash",
"vim script": "vim",
"dockerfile": "docker",
"gdscript": "godot",
"html": "html5",
};
export function getLanguageIconUrl(language: string | null): string {
if (!language) return "https://cdn.jsdelivr.net/gh/devicons/devicon@develop/icons/github/github-original.svg";
const normalized = LANGUAGE_ICON_MAP[language.toLowerCase()] ?? language.toLowerCase().replace(/\s+/g, "");
return `https://cdn.jsdelivr.net/gh/devicons/devicon@develop/icons/${normalized}/${normalized}-original.svg`;
}
export const PERSONAL_GROUP_KEY = "__personal__";
export function buildRepoGroups(username: string, personalRepos: GitHubRepo[], orgs: GitHubOrg[], orgRepos: Record<string, GitHubRepo[]>, avatarUrl?: string): RepoGroup[] {
const groups: RepoGroup[] = [];
if (personalRepos.length) {
groups.push({ key: PERSONAL_GROUP_KEY, label: username, avatarUrl, repos: personalRepos });
}
for (const org of orgs) {
const repos = orgRepos[org.login];
if (repos?.length) {
groups.push({ key: org.login, label: org.login, avatarUrl: org.avatar_url, repos });
}
}
return groups;
}
export function sortGroups(groups: RepoGroup[], mode: RepoSortMode): RepoGroup[] {
const personal = groups.find(g => g.key === PERSONAL_GROUP_KEY);
const rest = groups.filter(g => g.key !== PERSONAL_GROUP_KEY);
const sorted = [...rest].sort((a, b) => {
return mode === "alpha"
? a.label.localeCompare(b.label)
: b.repos.length - a.repos.length;
});
return personal ? [personal, ...sorted] : sorted;
}