From 5ab9c4c4c1195ad3b3f68bdee9da40403f0246f3 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Tue, 7 Jul 2026 17:39:31 +0200 Subject: [PATCH 1/3] feat: credits page with contributors list --- apps/core/src/common/contributors.ts | 119 +++++++++++ apps/core/src/routes/api/index.ts | 6 + apps/webpanel/package.json | 84 ++++---- apps/webpanel/src/hooks/use-contributors.ts | 33 +++ apps/webpanel/src/lib/utils.ts | 9 + apps/webpanel/src/pages/index.ts | 5 + apps/webpanel/src/pages/settings/credits.tsx | 213 +++++++++++++++++++ apps/webpanel/src/static/navigation.ts | 6 + packages/shared/src/types/contributors.ts | 11 + packages/shared/src/types/index.ts | 1 + 10 files changed, 445 insertions(+), 42 deletions(-) create mode 100644 apps/core/src/common/contributors.ts create mode 100644 apps/webpanel/src/hooks/use-contributors.ts create mode 100644 apps/webpanel/src/pages/settings/credits.tsx create mode 100644 packages/shared/src/types/contributors.ts diff --git a/apps/core/src/common/contributors.ts b/apps/core/src/common/contributors.ts new file mode 100644 index 00000000..d9f4e180 --- /dev/null +++ b/apps/core/src/common/contributors.ts @@ -0,0 +1,119 @@ +import type { Contributor, ContributorSummary } from '@fxmanager/shared/types'; +import { isProduction } from './utils'; + +const GITHUB_CONTRIBUTORS = + 'https://api.github.com/repos/fxManagerProject/fxManager/contributors'; +const DEFAULT_TTL_MS = 60 * 60 * 1000; // 1 hour +const REQUEST_TIMEOUT_MS = 5_000; + +interface RawContributor { + id: number; + login: string; + node_id: string; + avatar_url: string; + gravatar_id: string; + url: string; + html_url: string; + followers_url: string; + following_url: string; + gists_url: string; + starred_url: string; + subscriptions_url: string; + organizations_url: string; + repos_url: string; + events_url: string; + received_events_url: string; + type: string; + user_view_type: string; + site_admin: boolean; + contributions: number; +} + +const CORE_CONTRIBUTORS: Record = { + Maximus7474: { + kofi: 'Maximus7474', + username: 'Maximus7474', + }, + FjamZoo: { + kofi: 'FjamZoo', + username: 'FjamZoo', + }, + andreutu: { + username: 'andreutu', + }, +}; + +async function fetchContributors(): Promise { + const response = await fetch(GITHUB_CONTRIBUTORS, { + headers: { 'User-Agent': 'fxManager-Updater' }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + + if (!response.ok) + throw new Error(`${response.status} - ${response.statusText}`); + + const data = (await response.json()) as RawContributor[]; + + return data; +} + +/** + * TTL-cached version status provider for the API/UI. Mirrors the + * recommended-artifact fetcher: caches success, falls back to the last known + * value (or a safe "no update" state) on failure so it never blocks callers. + */ +export function createContributorsList(opts?: { + ttlMs?: number; + now?: () => number; +}) { + const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS; + const now = opts?.now ?? Date.now; + let cache: { value: ContributorSummary; expiresAt: number } | null = null; + + const noUpdate = (): ContributorSummary => ({ + core: Object.values(CORE_CONTRIBUTORS), + external: [], + }); + + return async function getContributors(): Promise { + if (!isProduction) return noUpdate(); + if (cache && cache.expiresAt > now()) return cache.value; + + try { + const data = await fetchContributors(); + + const value: ContributorSummary = { + core: [], + external: [], + }; + + for (const contributor of data) { + if (CORE_CONTRIBUTORS[contributor.login]) { + value.core.push({ + ...CORE_CONTRIBUTORS[contributor.login], + username: contributor.login, + image: contributor.avatar_url, + contributions: contributor.contributions, + }); + } else if (contributor.type === 'User') { + value.external.push({ + username: contributor.login, + image: contributor.avatar_url, + contributions: contributor.contributions, + }); + } + } + + cache = { value, expiresAt: now() + ttlMs }; + return value; + } catch (err) { + console.error( + `[version] Could not fetch contributors:`, + (err as Error).message, + ); + return cache?.value ?? noUpdate(); + } + }; +} + +export const getContributorsList = createContributorsList(); diff --git a/apps/core/src/routes/api/index.ts b/apps/core/src/routes/api/index.ts index 8487945f..3eba9796 100644 --- a/apps/core/src/routes/api/index.ts +++ b/apps/core/src/routes/api/index.ts @@ -11,6 +11,7 @@ import MigrateModule from './migrate'; import DisconnectsModule from './disconnects'; import PerfModule from './perf'; import ConfigModule from './config'; +import { getContributorsList } from '../../common/contributors'; const apiRoutes: RouteModule['handler'] = async (fastify, options) => { fastify.register(SetupModule.handler, { @@ -71,6 +72,11 @@ const apiRoutes: RouteModule['handler'] = async (fastify, options) => { ...options, prefix: ConfigModule.prefix, }); + + fastify.get('/contributors', async (_request, reply) => { + const contributors = await getContributorsList(); + return reply.code(200).send(contributors); + }); }; export default apiRoutes; diff --git a/apps/webpanel/package.json b/apps/webpanel/package.json index a210eafa..32385883 100644 --- a/apps/webpanel/package.json +++ b/apps/webpanel/package.json @@ -1,44 +1,44 @@ { - "name": "@fxmanager/webpanel", - "version": "0.3.0", - "type": "module", - "private": true, - "scripts": { - "dev": "vite", - "build": "tsc -b && vite build", - "lint": "biome check .", - "format": "biome format --write .", - "typecheck": "tsc --noEmit", - "preview": "vite preview" - }, - "dependencies": { - "@codemirror/commands": "^6.10.4", - "@codemirror/language": "^6.12.4", - "@codemirror/state": "^6.7.0", - "@codemirror/theme-one-dark": "^6.1.3", - "@codemirror/view": "^6.43.4", - "@fxmanager/shared": "workspace:*", - "@fxmanager/ui": "workspace:*", - "@lezer/highlight": "^1.2.3", - "ansi-to-react": "^6.2.6", - "codemirror": "^6.0.2", - "date-fns": "^4.2.1", - "lucide-react": "^1.0.1", - "react": "^19.2.4", - "react-day-picker": "^10.0.1", - "react-dom": "^19.2.4", - "react-router-dom": "^7.13.2", - "recharts": "3.8.0", - "sonner": "^2.0.7" - }, - "devDependencies": { - "@fxmanager/database": "workspace:*", - "@tailwindcss/vite": "^4.1.18", - "@types/node": "^25.1.0", - "@types/react": "^19.2.10", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.1", - "typescript": "^5.9.3", - "vite": "^7.2.4" - } + "name": "@fxmanager/webpanel", + "version": "0.3.0", + "type": "module", + "private": true, + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "biome check .", + "format": "biome format --write .", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "@codemirror/commands": "^6.10.4", + "@codemirror/language": "^6.12.4", + "@codemirror/state": "^6.7.0", + "@codemirror/theme-one-dark": "^6.1.3", + "@codemirror/view": "^6.43.4", + "@fxmanager/shared": "workspace:*", + "@fxmanager/ui": "workspace:*", + "@lezer/highlight": "^1.2.3", + "ansi-to-react": "^6.2.6", + "codemirror": "^6.0.2", + "date-fns": "^4.2.1", + "lucide-react": "^1.0.1", + "react": "^19.2.4", + "react-day-picker": "^10.0.1", + "react-dom": "^19.2.4", + "react-router-dom": "^7.13.2", + "recharts": "3.8.0", + "sonner": "^2.0.7" + }, + "devDependencies": { + "@fxmanager/database": "workspace:*", + "@tailwindcss/vite": "^4.1.18", + "@types/node": "^25.1.0", + "@types/react": "^19.2.10", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "typescript": "^5.9.3", + "vite": "^7.2.4" + } } diff --git a/apps/webpanel/src/hooks/use-contributors.ts b/apps/webpanel/src/hooks/use-contributors.ts new file mode 100644 index 00000000..ff0acce6 --- /dev/null +++ b/apps/webpanel/src/hooks/use-contributors.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from 'react'; +import { QueryService } from '@/lib/query'; +import type { ContributorSummary } from '@fxmanager/shared/types'; + +export function useContributorsList(): { + loading: boolean; + contributors: ContributorSummary | null; +} { + const [contributors, setContributors] = useState( + null, + ); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let active = true; + + QueryService({ + endpoint: '/contributors', + method: 'GET', + }) + .then((res) => { + if (active) setContributors(res); + }) + .catch(() => {}) + .finally(() => setLoading(false)); + + return () => { + active = false; + }; + }, []); + + return { loading, contributors }; +} diff --git a/apps/webpanel/src/lib/utils.ts b/apps/webpanel/src/lib/utils.ts index a214e453..a4a4e7c5 100644 --- a/apps/webpanel/src/lib/utils.ts +++ b/apps/webpanel/src/lib/utils.ts @@ -76,3 +76,12 @@ const SERVER_RUNNING_STATES: ProcessState[] = [ export function isServerRunning(status?: ProcessState): boolean { return status ? SERVER_RUNNING_STATES.includes(status) : false; } + +export function formatNumber(num: number): string { + if (num < 1000) return num.toString(); + if (num < 1e6) return `${Math.round(num / 100) / 10}k`; + if (num < 1e9) return `${Math.round(num / 100) / 10}M`; + + // Just in case some funny person has a really large number + return `${Math.round(num / 100) / 10}B`; +} diff --git a/apps/webpanel/src/pages/index.ts b/apps/webpanel/src/pages/index.ts index 979dba3d..9acff47c 100644 --- a/apps/webpanel/src/pages/index.ts +++ b/apps/webpanel/src/pages/index.ts @@ -16,6 +16,7 @@ import WhitelistIndex from './whitelist'; import AuditLogPage from './settings/auditlogs'; import ConfigEditor from './settings/configeditor'; import PerformancePage from './performance'; +import CreditsPage from './settings/credits'; type RouteConfig = { path: string; @@ -92,4 +93,8 @@ export const routes: RouteConfig[] = [ element: GroupManagement, permission: UserPermissions.SETTINGS_ADMIN_MANAGEMENT, }, + { + path: '/settings/credits', + element: CreditsPage, + }, ]; diff --git a/apps/webpanel/src/pages/settings/credits.tsx b/apps/webpanel/src/pages/settings/credits.tsx new file mode 100644 index 00000000..693ee6c4 --- /dev/null +++ b/apps/webpanel/src/pages/settings/credits.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react'; +import { PageHeader } from '@/components/page-header'; +import { ScrollArea } from '@fxmanager/ui/components/scroll-area'; +import { AlertCircle, ListCheck, Loader2 } from 'lucide-react'; +import { formatNumber, initials } from '@/lib/utils'; +import type { Contributor } from '@fxmanager/shared/types'; +import { useContributorsList } from '@/hooks/use-contributors'; + +export default function CreditsPage() { + const { contributors, loading } = useContributorsList(); + + if (loading) { + return ( +
+ +
+ + Fetching contributors... +
+
+ ); + } + + const hasCore = contributors?.core && contributors.core.length > 0; + const hasExternal = + contributors?.external && contributors.external.length > 0; + + return ( +
+ + + +
+
+
+

+ Core Contributors +

+ + {contributors?.core?.length || 0} + +
+ + {hasCore ? ( +
+ {contributors.core.map((contributor) => ( + + ))} +
+ ) : !hasExternal ? ( +
+ +
+

+ No contributors were loaded +

+

+ Failed to fetch profile data. Please try reloading the page. +

+
+
+ ) : ( +

+ No core contributors listed. +

+ )} +
+ + {hasExternal && ( +
+
+

+ External Contributors +

+ + {contributors.external.length} + +
+ +
+ {contributors.external.map((contributor) => ( + + ))} +
+
+ )} +
+
+
+ ); +} + +function ContributorCard({ + contributor, + isCore = false, +}: { + contributor: Contributor; + isCore?: boolean; +}) { + const [imageError, setImageError] = useState(false); + + return ( +
+
+
+ {!imageError ? ( + {`${contributor.username}'s setImageError(true)} + /> + ) : ( + initials(contributor.username) + )} +
+ +
+ + {contributor.username} + + {contributor.contributions && ( + + {formatNumber(contributor.contributions)} contributions + + )} +
+
+ +
+ + + + + {contributor.kofi && ( + + + + + + + + + + )} +
+
+ ); +} diff --git a/apps/webpanel/src/static/navigation.ts b/apps/webpanel/src/static/navigation.ts index cb99536f..85faf183 100644 --- a/apps/webpanel/src/static/navigation.ts +++ b/apps/webpanel/src/static/navigation.ts @@ -11,6 +11,7 @@ import { ChartBar, FileCog, UsersRound, + ListCheck, } from 'lucide-react'; import type { NavCategory } from '@/types/sidebar'; import { UserPermissions } from '@fxmanager/shared/constants'; @@ -98,6 +99,11 @@ const NAV_CONFIGURATION: NavCategory = { icon: ScrollText, permission: UserPermissions.AUDIT_LOG, }, + { + url: '/settings/credits/', + title: 'Credits', + icon: ListCheck, + }, ], }; diff --git a/packages/shared/src/types/contributors.ts b/packages/shared/src/types/contributors.ts new file mode 100644 index 00000000..5d432b60 --- /dev/null +++ b/packages/shared/src/types/contributors.ts @@ -0,0 +1,11 @@ +export interface Contributor { + username: string; + kofi?: string; + image?: string | false; + contributions?: number; +} + +export interface ContributorSummary { + core: Contributor[]; + external: Contributor[]; +} diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 14fc7ce0..cb952db4 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -1,6 +1,7 @@ export * from './api'; export * from './audit'; export * from './config'; +export * from './contributors'; export * from './core'; export * from './discord-manager'; export * from './game'; From 15df1d722b778f2b8c911f7a013ce4fe8b3a28c5 Mon Sep 17 00:00:00 2001 From: Maximus7474 Date: Tue, 7 Jul 2026 18:42:29 +0200 Subject: [PATCH 2/3] feat(core/common): tests for contributors getter --- apps/core/src/common/contributors.test.ts | 180 ++++++++++++++++++++++ apps/core/src/common/contributors.ts | 12 +- 2 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 apps/core/src/common/contributors.test.ts diff --git a/apps/core/src/common/contributors.test.ts b/apps/core/src/common/contributors.test.ts new file mode 100644 index 00000000..aa8b1f92 --- /dev/null +++ b/apps/core/src/common/contributors.test.ts @@ -0,0 +1,180 @@ +/** biome-ignore-all lint/suspicious/noExplicitAny lint/complexity/noBannedTypes: explicit any allows mocking global fetch frames */ +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test'; +import { createContributorsList } from './contributors'; + +describe('createContributorsList', () => { + let originalFetch: typeof globalThis.fetch; + let errorSpy: ReturnType; + + // Helper to mock successful GitHub Contributors responses + const mockGitHubContributors = (contributors: any[], status = 200) => { + globalThis.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify(contributors), { + status, + statusText: status === 200 ? 'OK' : 'Internal Server Error', + }), + ), + ) as any; + }; + + // Helper data fixtures + const mockRawCore = { + id: 1, + login: 'Maximus7474', + avatar_url: 'https://avatar.com/max', + type: 'User', + contributions: 42, + }; + + const mockRawExternal = { + id: 2, + login: 'GhostCoder', + avatar_url: 'https://avatar.com/ghost', + type: 'User', + contributions: 10, + }; + + const mockRawBot = { + id: 3, + login: 'dependabot[bot]', + avatar_url: 'https://avatar.com/bot', + type: 'Bot', + contributions: 100, + }; + + beforeEach(() => { + originalFetch = globalThis.fetch; + errorSpy = spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + errorSpy.mockRestore(); + }); + + it('should return a default core list when NOT in production', async () => { + globalThis.fetch = mock(() => Promise.resolve(new Response('[]'))) as any; + + const getContributors = createContributorsList({ isProd: false }); + const result = await getContributors(); + + expect(result.core).toHaveLength(3); + expect(globalThis.fetch).toBeCalledTimes(0); + }); + + it('should correctly filter and map core vs external vs bot contributors', async () => { + mockGitHubContributors([mockRawCore, mockRawExternal, mockRawBot]); + const getContributors = createContributorsList({ isProd: true }); + + const result = await getContributors(); + + // Check Core matching + expect(result.core).toHaveLength(1); + expect(result.core[0]).toEqual({ + kofi: 'Maximus7474', + username: 'Maximus7474', + image: 'https://avatar.com/max', + contributions: 42, + }); + + // Check External matching + expect(result.external).toHaveLength(1); + expect(result.external[0]).toEqual({ + username: 'GhostCoder', + image: 'https://avatar.com/ghost', + contributions: 10, + }); + + // Bots should be skipped entirely + const allReturnedLogins = [ + ...result.core.map((c) => c.username), + ...result.external.map((c) => c.username), + ]; + expect(allReturnedLogins).not.toContain('dependabot[bot]'); + }); + + it('should use cached value and avoid network calls before TTL expires', async () => { + let mockTime = 1000; + const timeMock = () => mockTime; + + mockGitHubContributors([mockRawCore]); + const getContributors = createContributorsList({ + isProd: true, + ttlMs: 5000, + now: timeMock, + }); + + // First call hits the network + const firstResult = await getContributors(); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + + // Second call within TTL returns cached data without calling fetch again + mockTime = 4000; // +3000ms elapsed (TTL is 5000) + const secondResult = await getContributors(); + expect(globalThis.fetch).toHaveBeenCalledTimes(1); + expect(secondResult).toEqual(firstResult); + }); + + it('should refresh from network after TTL expires', async () => { + let mockTime = 1000; + const timeMock = () => mockTime; + + mockGitHubContributors([mockRawCore]); + const getContributors = createContributorsList({ + isProd: true, + ttlMs: 5000, + now: timeMock, + }); + + await getContributors(); // Fetch #1 + + // Advance time past expiration + mockTime = 7000; // +6000ms elapsed + await getContributors(); // Fetch #2 + + expect(globalThis.fetch).toHaveBeenCalledTimes(2); + }); + + it('should gracefully return fallback empty arrays if first network request fails', async () => { + mockGitHubContributors([], 500); + const getContributors = createContributorsList({ isProd: true }); + + const result = await getContributors(); + + expect(errorSpy).toHaveBeenCalled(); + expect(result.core).toHaveLength(3); // Hardcoded fallback defaults + expect(result.external).toHaveLength(0); + }); + + it('should fall back to last known cached values if network request fails after a success', async () => { + let mockTime = 1000; + const timeMock = () => mockTime; + + mockGitHubContributors([mockRawExternal]); + const getContributors = createContributorsList({ + isProd: true, + ttlMs: 1000, + now: timeMock, + }); + + await getContributors(); + + mockTime = 3000; + mockGitHubContributors([], 500); + + const fallbackResult = await getContributors(); + + expect(errorSpy).toHaveBeenCalled(); + expect(fallbackResult.external).toHaveLength(1); + expect(fallbackResult.external?.[0]?.username).toBe('GhostCoder'); + }); +}); diff --git a/apps/core/src/common/contributors.ts b/apps/core/src/common/contributors.ts index d9f4e180..601aeceb 100644 --- a/apps/core/src/common/contributors.ts +++ b/apps/core/src/common/contributors.ts @@ -29,7 +29,7 @@ interface RawContributor { contributions: number; } -const CORE_CONTRIBUTORS: Record = { +export const CORE_CONTRIBUTORS: Record = { Maximus7474: { kofi: 'Maximus7474', username: 'Maximus7474', @@ -62,9 +62,10 @@ async function fetchContributors(): Promise { * recommended-artifact fetcher: caches success, falls back to the last known * value (or a safe "no update" state) on failure so it never blocks callers. */ -export function createContributorsList(opts?: { +export function createContributorsList(opts: { ttlMs?: number; now?: () => number; + isProd: boolean; }) { const ttlMs = opts?.ttlMs ?? DEFAULT_TTL_MS; const now = opts?.now ?? Date.now; @@ -76,7 +77,8 @@ export function createContributorsList(opts?: { }); return async function getContributors(): Promise { - if (!isProduction) return noUpdate(); + console.log('getContributors called, is in production', opts.isProd); + if (!opts.isProd) return noUpdate(); if (cache && cache.expiresAt > now()) return cache.value; try { @@ -116,4 +118,6 @@ export function createContributorsList(opts?: { }; } -export const getContributorsList = createContributorsList(); +export const getContributorsList = createContributorsList({ + isProd: isProduction, +}); From 59664a341a07cba4f828758a30cd7534c4d597e4 Mon Sep 17 00:00:00 2001 From: Maximus Date: Wed, 8 Jul 2026 17:37:53 +0200 Subject: [PATCH 3/3] chore(core/contributors): removed leftover debug logging Co-authored-by: andreutu <91362974+andreutu@users.noreply.github.com> --- apps/core/src/common/contributors.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/core/src/common/contributors.ts b/apps/core/src/common/contributors.ts index 601aeceb..91df35e0 100644 --- a/apps/core/src/common/contributors.ts +++ b/apps/core/src/common/contributors.ts @@ -77,7 +77,6 @@ export function createContributorsList(opts: { }); return async function getContributors(): Promise { - console.log('getContributors called, is in production', opts.isProd); if (!opts.isProd) return noUpdate(); if (cache && cache.expiresAt > now()) return cache.value;