feat(panel): add sponsor slots fed from sponsors.sanaei.dev

Monthly sponsor placements need to change without cutting a panel
release. Panels now read 3X/sponsors.json from the MHSanaei/sponsors
repo (GitHub Pages on sponsors.sanaei.dev) and show active sponsors in
four slots: an overview banner, a rotating sidebar card (max three), the
login page and a new Sponsors page that also lists open placements.

An entry shows only while enable is not false and until is in the
future; links must be https and logos are png/webp/jpg by name only.
The list is cached for an hour and the last good copy survives upstream
failures; logos are proxied through /sponsors/logo/:name with failures
cached, so CSP stays 'self' and admin browsers never reach a third
party. Admins can hide a slot for 24h. Under XUI_DEBUG the panel reads
a sibling ../sponsors/3X checkout so edits can be previewed before push.
This commit is contained in:
MHSanaei
2026-09-26 03:31:51 +02:00
parent 89e200ead4
commit fd7b3559bc
47 changed files with 2323 additions and 10 deletions
+4
View File
@@ -196,6 +196,10 @@ export async function httpRequest(
return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
}
export function withBasePath(path: string): string {
return basePathPrefix + path;
}
export function setupHttp(): void {
let basePath: string | null | undefined = window.X_UI_BASE_PATH;
if (!basePath) {
@@ -0,0 +1,23 @@
import { useQuery } from '@tanstack/react-query';
import { HttpUtil } from '@/utils';
import { keys } from '@/api/queryKeys';
import type { SponsorList } from '@/generated/types';
const EMPTY: SponsorList = { sponsors: [] };
async function fetchSponsors(): Promise<SponsorList> {
const msg = await HttpUtil.get<SponsorList>('/sponsors', undefined, { silent: true });
if (!msg?.success || !msg.obj) return EMPTY;
return { contact: msg.obj.contact, sponsors: msg.obj.sponsors ?? [] };
}
export function useSponsorsQuery() {
const query = useQuery({
queryKey: keys.sponsors(),
queryFn: fetchSponsors,
staleTime: 60 * 60 * 1000,
retry: false,
});
return { data: query.data ?? EMPTY, fetched: query.isFetched };
}
+1
View File
@@ -1,4 +1,5 @@
export const keys = {
sponsors: () => ['sponsors'] as const,
server: {
status: () => ['server', 'status'] as const,
fail2banStatus: () => ['server', 'fail2banStatus'] as const,