mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-23 22:35:03 +03:00
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:
@@ -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',
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -411,3 +411,7 @@
|
||||
.submit-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-sponsor {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user