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
+7
View File
@@ -236,6 +236,13 @@ export const sections: readonly Section[] = [
'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
response: '{\n "success": true,\n "obj": "csrf-token-string"\n}',
},
{
method: 'GET',
path: '/sponsors',
summary:
'Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.',
responseSchema: 'SponsorList',
},
{
method: 'POST',
path: '/getTwoFactorEnable',
+3
View File
@@ -22,6 +22,7 @@ import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import AppSidebar from '@/layouts/AppSidebar';
import { LazyMount } from '@/components/utility';
import SponsorSlot from '@/components/sponsor/SponsorSlot';
import { setMessageInstance } from '@/utils/messageBus';
import OverviewActionBar from './OverviewActionBar';
import VitalTile from './VitalTile';
@@ -213,6 +214,8 @@ export default function IndexPage() {
onOpenVersionSwitch={() => setVersionOpen(true)}
/>
<SponsorSlot slot="dashboard" />
{health && (
<div className="ov-health" style={{ color: health.color }}>
<span className="ov-health-mark" />
+4
View File
@@ -411,3 +411,7 @@
.submit-row {
margin-bottom: 0;
}
.login-sponsor {
margin-top: 20px;
}
+2
View File
@@ -26,6 +26,7 @@ import { FormProvider, useForm } from 'react-hook-form';
import { HttpUtil, LanguageManager } from '@/utils';
import { FormField, rhfZodValidate } from '@/components/form/rhf';
import { setMessageInstance } from '@/utils/messageBus';
import SponsorSlot from '@/components/sponsor/SponsorSlot';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
import './LoginPage.css';
@@ -247,6 +248,7 @@ export default function LoginPage() {
</Form.Item>
</Form>
</FormProvider>
<SponsorSlot slot="login" variant="compact" className="login-sponsor" />
</div>
)}
</div>
@@ -0,0 +1,142 @@
.sponsors-page .ant-layout,
.sponsors-page .ant-layout-content,
.sponsors-page .content-shell {
background: transparent;
}
.sponsors-page .content-area {
padding: 24px;
}
.sponsors-inner {
max-width: 1200px;
}
.sponsors-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.sponsors-page .sponsors-title {
margin: 0 0 4px;
}
.sponsors-title-icon {
color: #faad14;
}
.sponsors-page .sponsors-section-title {
margin: 32px 0 12px;
color: var(--ant-color-text-secondary);
font-weight: 600;
letter-spacing: 0.3px;
}
.sponsors-yourbrand {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
height: 100%;
min-height: 170px;
padding: 16px;
border: 1.5px dashed var(--ant-color-border);
border-radius: var(--ant-border-radius-lg, 8px);
background: transparent;
}
.sponsors-yourbrand.is-large {
align-items: center;
min-height: 0;
padding: 40px 24px;
text-align: center;
background: var(--bg-card, transparent);
}
.sponsors-yourbrand-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 56px;
height: 56px;
border-radius: 8px;
font-size: 22px;
color: var(--ant-color-primary);
background: var(--ant-color-primary-bg);
}
.sponsors-yourbrand-title {
font-weight: 600;
font-size: 15px;
color: var(--ant-color-text);
}
.sponsors-yourbrand-text {
max-width: 420px;
margin-bottom: 4px;
font-size: 13px;
color: var(--ant-color-text-secondary);
}
.sponsors-yourbrand:not(.is-large) .ant-btn {
margin-top: auto;
}
.sponsors-placement {
display: flex;
gap: 12px;
height: 100%;
padding: 14px 16px;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg, 8px);
background: var(--bg-card, transparent);
}
.sponsors-placement-icon {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 17px;
color: var(--ant-color-primary);
background: var(--ant-color-primary-bg);
}
.sponsors-placement-title {
font-weight: 600;
color: var(--ant-color-text);
}
.sponsors-placement-body {
min-width: 0;
}
.sponsors-placement-status {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 8px;
}
.sponsors-placement-status .ant-tag {
margin: 0;
}
.sponsors-placement-desc {
font-size: 13px;
color: var(--ant-color-text-secondary);
}
@media (max-width: 768px) {
.sponsors-page .content-area {
padding: 12px;
padding-top: 64px;
}
}
@@ -0,0 +1,176 @@
import { useMemo } from 'react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Col, ConfigProvider, Layout, Row, Spin, Tag, Typography } from 'antd';
import {
CrownOutlined,
DashboardOutlined,
LoginOutlined,
MenuUnfoldOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { useTheme } from '@/hooks/useTheme';
import AppSidebar from '@/layouts/AppSidebar';
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
import SponsorCard from '@/components/sponsor/SponsorCard';
import { IntlUtil } from '@/utils';
import { useDatepicker } from '@/hooks/useDatepicker';
import { placementStatus, sponsorsForSlot, type SponsorSlot } from '@/lib/sponsors';
import './SponsorsPage.css';
function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
const { t } = useTranslation();
if (!contact) return null;
return (
<Button
type="primary"
icon={<CrownOutlined />}
href={contact}
target="_blank"
rel="noopener noreferrer"
block={block}
>
{t('pages.sponsors.become')}
</Button>
);
}
function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) {
const { t } = useTranslation();
return (
<div className={`sponsors-yourbrand${large ? ' is-large' : ''}`}>
<span className="sponsors-yourbrand-icon">
<PlusOutlined />
</span>
<div className="sponsors-yourbrand-title">{t('pages.sponsors.yourBrand')}</div>
<div className="sponsors-yourbrand-text">{t('pages.sponsors.yourBrandText')}</div>
<BecomeButton contact={contact} />
</div>
);
}
export default function SponsorsPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { data, fetched } = useSponsorsQuery();
const sponsors = sponsorsForSlot(data.sponsors, 'page');
const { datepicker } = useDatepicker();
const placements: { slot: SponsorSlot; icon: ReactNode; title: string; desc: string }[] = [
{
slot: 'dashboard',
icon: <DashboardOutlined />,
title: t('pages.sponsors.placementDashboard'),
desc: t('pages.sponsors.placementDashboardDesc'),
},
{
slot: 'sidebar',
icon: <MenuUnfoldOutlined />,
title: t('pages.sponsors.placementSidebar'),
desc: t('pages.sponsors.placementSidebarDesc'),
},
{
slot: 'login',
icon: <LoginOutlined />,
title: t('pages.sponsors.placementLogin'),
desc: t('pages.sponsors.placementLoginDesc'),
},
{
slot: 'page',
icon: <CrownOutlined />,
title: t('pages.sponsors.placementPage'),
desc: t('pages.sponsors.placementPageDesc'),
},
];
const pageClass = useMemo(() => {
const classes = ['sponsors-page'];
if (isDark) classes.push('is-dark');
if (isUltra) classes.push('is-ultra');
return classes.join(' ');
}, [isDark, isUltra]);
return (
<ConfigProvider theme={antdThemeConfig}>
<Layout className={pageClass}>
<AppSidebar />
<Layout className="content-shell">
<Layout.Content className="content-area">
<div className="sponsors-inner">
<div className="sponsors-header">
<div>
<Typography.Title level={3} className="sponsors-title">
<CrownOutlined className="sponsors-title-icon" /> {t('pages.sponsors.title')}
</Typography.Title>
<Typography.Text type="secondary">{t('pages.sponsors.intro')}</Typography.Text>
</div>
<BecomeButton contact={data.contact} />
</div>
<Spin spinning={!fetched} delay={200}>
{sponsors.length === 0 ? (
fetched && <YourBrandCard contact={data.contact} large />
) : (
<Row gutter={[16, 16]}>
{sponsors.map((sponsor) => (
<Col key={sponsor.id} xs={24} sm={12} xl={8}>
<SponsorCard sponsor={sponsor} variant="card" />
</Col>
))}
{data.contact && (
<Col xs={24} sm={12} xl={8}>
<YourBrandCard contact={data.contact} />
</Col>
)}
</Row>
)}
</Spin>
<Typography.Title level={5} className="sponsors-section-title">
{t('pages.sponsors.placements')}
</Typography.Title>
<Row gutter={[16, 16]}>
{placements.map((p) => {
const status = placementStatus(data.sponsors, p.slot);
return (
<Col key={p.slot} xs={24} sm={12} xl={6}>
<div className="sponsors-placement">
<span className="sponsors-placement-icon">{p.icon}</span>
<div className="sponsors-placement-body">
<div className="sponsors-placement-title">{p.title}</div>
<div className="sponsors-placement-desc">{p.desc}</div>
<div className="sponsors-placement-status">
{status.takenUntil ? (
<Tag color="orange">
{t('pages.sponsors.takenUntil', {
date: IntlUtil.formatDate(status.takenUntil, datepicker),
})}
</Tag>
) : (
<Tag color="green">{t('pages.sponsors.available')}</Tag>
)}
{status.count > 0 && !status.takenUntil && (
<Tag>
{t('pages.sponsors.activeCount', {
count:
status.capacity && status.capacity > 1
? `${status.count}/${status.capacity}`
: status.count,
})}
</Tag>
)}
</div>
</div>
</div>
</Col>
);
})}
</Row>
</div>
</Layout.Content>
</Layout>
</Layout>
</ConfigProvider>
);
}