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
@@ -13,6 +13,7 @@ import {
ClusterOutlined,
CodeOutlined,
CopyOutlined,
CrownOutlined,
DashboardOutlined,
DatabaseOutlined,
DiscordOutlined,
@@ -418,6 +419,12 @@ export default function CommandPalette() {
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
icon: <ApiOutlined />,
},
{
path: '/sponsors',
title: t('menu.sponsors'),
keywords: ['sponsors', 'sponsor', 'partners'],
icon: <CrownOutlined />,
},
];
pages
@@ -0,0 +1,216 @@
.sponsor-card {
position: relative;
display: flex;
align-items: stretch;
min-width: 0;
border: 1px solid var(--ant-color-border-secondary);
border-radius: var(--ant-border-radius-lg, 8px);
background: var(--bg-card, var(--ant-color-fill-quaternary));
transition:
border-color 0.2s,
background 0.2s;
}
.sponsor-card:hover {
border-color: var(--ant-color-primary);
}
.sponsor-main {
display: flex;
flex: 1;
align-items: center;
gap: 12px;
min-width: 0;
padding: 12px 16px;
color: var(--ant-color-text);
text-decoration: none;
}
.sponsor-main:hover,
.sponsor-main:focus-visible {
color: var(--ant-color-text);
outline: none;
}
.sponsor-logo {
flex: 0 0 auto;
width: 40px;
height: 40px;
border-radius: 8px;
object-fit: contain;
background: var(--ant-color-bg-elevated);
}
.sponsor-logo-fallback {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 600;
font-size: 18px;
color: var(--ant-color-primary);
background: var(--ant-color-primary-bg);
}
.sponsor-body {
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.sponsor-head {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.sponsor-tag {
flex: 0 0 auto;
padding: 0 6px;
border: 1px solid var(--ant-color-border);
border-radius: 4px;
font-size: 11px;
line-height: 18px;
color: var(--ant-color-text-tertiary);
}
.sponsor-title {
overflow: hidden;
font-weight: 600;
white-space: nowrap;
text-overflow: ellipsis;
}
.sponsor-text {
font-size: 13px;
color: var(--ant-color-text-secondary);
}
.sponsor-visit {
flex: 0 0 auto;
font-size: 13px;
color: var(--ant-color-primary);
white-space: nowrap;
}
.sponsor-close {
flex: 0 0 auto;
align-self: flex-start;
width: 28px;
height: 28px;
margin: 6px 6px 0 0;
padding: 0;
border: none;
border-radius: 6px;
background: transparent;
color: var(--ant-color-text-tertiary);
cursor: pointer;
}
.sponsor-close:hover,
.sponsor-close:focus-visible {
color: var(--ant-color-text);
background: var(--ant-color-fill-tertiary);
outline: none;
}
[dir='rtl'] .sponsor-close {
margin: 6px 0 0 6px;
}
.sponsor-card-compact .sponsor-main {
gap: 10px;
padding: 8px 10px;
}
.sponsor-card-compact .sponsor-logo {
width: 32px;
height: 32px;
}
.sponsor-card-compact .sponsor-head {
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.sponsor-card-compact .sponsor-title {
display: -webkit-box;
max-width: 100%;
font-size: 13px;
white-space: normal;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.sponsor-card-compact .sponsor-text {
display: -webkit-box;
overflow: hidden;
font-size: 12px;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.sponsor-card-compact .sponsor-close {
width: 22px;
height: 22px;
margin: 4px 4px 0 0;
font-size: 11px;
}
.sponsor-card-card {
height: 100%;
}
.sponsor-card-card .sponsor-main {
flex-direction: column;
align-items: flex-start;
padding: 16px;
}
.sponsor-card-card .sponsor-logo {
width: 56px;
height: 56px;
}
.sponsor-card-card .sponsor-visit {
margin-top: auto;
}
.sponsor-card-icon {
justify-content: center;
padding: 6px;
border-color: transparent;
background: transparent;
}
.sponsor-card-icon .sponsor-logo {
width: 32px;
height: 32px;
}
@media (max-width: 576px) {
.sponsor-card-banner .sponsor-main {
flex-wrap: wrap;
padding: 10px 12px;
}
.sponsor-card-banner .sponsor-visit {
flex-basis: 100%;
padding-inline-start: 52px;
}
}
.sponsor-card-card {
transition:
border-color 0.2s,
transform 0.2s,
box-shadow 0.2s;
}
.sponsor-card-card:hover {
transform: translateY(-2px);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
}
@@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import type { Sponsor } from '@/generated/types';
import SponsorCard from './SponsorCard';
const sponsor: Sponsor = {
id: 'acme-2026-10',
name: 'Acme VPS',
slots: ['dashboard', 'sidebar', 'page', 'login'],
until: '2099-01-01T00:00:00Z',
title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
link: 'https://acme.example/?utm_source=3x-ui',
};
const meta = {
title: 'Sponsor/SponsorCard',
component: SponsorCard,
tags: ['autodocs'],
parameters: {
docs: {
description: {
component:
'Paid sponsor placement fed by the project sponsors.json. Always labelled as a sponsor; links open in a new tab with rel="sponsored".',
},
},
},
argTypes: {
sponsor: { description: 'Sponsor entry from GET /sponsors.' },
variant: {
description: 'banner (dashboard), compact (sidebar/login) or card (Sponsors page).',
},
iconOnly: { description: 'Logo-only rendering for the collapsed sidebar rail.' },
onClose: { description: 'When set, shows a close button (temporary dismiss).' },
},
args: { sponsor },
} satisfies Meta<typeof SponsorCard>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Banner: Story = {
args: { variant: 'banner', onClose: () => {} },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('Sponsor')).toBeInTheDocument();
await expect(canvas.getByRole('link')).toHaveAttribute('rel', 'noopener noreferrer sponsored');
},
};
export const Compact: Story = {
args: { variant: 'compact', onClose: () => {} },
render: (args) => (
<div style={{ width: 204 }}>
<SponsorCard {...args} />
</div>
),
};
export const Card: Story = {
args: { variant: 'card' },
render: (args) => (
<div style={{ width: 320 }}>
<SponsorCard {...args} />
</div>
),
};
export const IconOnly: Story = {
args: { iconOnly: true },
};
@@ -0,0 +1,101 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseOutlined, ExportOutlined } from '@ant-design/icons';
import { withBasePath } from '@/api/http-init';
import type { Sponsor } from '@/generated/types';
import { pickLocale } from '@/lib/sponsors';
import './SponsorCard.css';
export type SponsorCardVariant = 'banner' | 'compact' | 'card';
export interface SponsorCardProps {
sponsor: Sponsor;
variant?: SponsorCardVariant;
iconOnly?: boolean;
onClose?: () => void;
}
function SponsorLogo({ sponsor }: { sponsor: Sponsor }) {
const [failedSrc, setFailedSrc] = useState('');
if (sponsor.logo && failedSrc !== sponsor.logo) {
return (
<img
className="sponsor-logo"
src={withBasePath(sponsor.logo)}
alt=""
loading="lazy"
onError={() => setFailedSrc(sponsor.logo ?? '')}
/>
);
}
return (
<span className="sponsor-logo sponsor-logo-fallback" aria-hidden="true">
{sponsor.name.slice(0, 1).toUpperCase()}
</span>
);
}
export default function SponsorCard({
sponsor,
variant = 'banner',
iconOnly = false,
onClose,
}: SponsorCardProps) {
const { t, i18n } = useTranslation();
const lang = i18n.resolvedLanguage || i18n.language || 'en';
const title = pickLocale(sponsor.title, lang) || sponsor.name;
const text = pickLocale(sponsor.text, lang);
const tag = t('pages.sponsors.tag');
if (iconOnly) {
return (
<a
className="sponsor-card sponsor-card-icon"
href={sponsor.link}
target="_blank"
rel="noopener noreferrer sponsored"
title={`${tag} · ${title}`}
aria-label={`${tag}: ${title}`}
>
<SponsorLogo sponsor={sponsor} />
</a>
);
}
return (
<div className={`sponsor-card sponsor-card-${variant}`}>
<a
className="sponsor-main"
href={sponsor.link}
target="_blank"
rel="noopener noreferrer sponsored"
>
<SponsorLogo sponsor={sponsor} />
<span className="sponsor-body" dir="auto">
<span className="sponsor-head">
<span className="sponsor-tag">{tag}</span>
<span className="sponsor-title">{title}</span>
</span>
{text && <span className="sponsor-text">{text}</span>}
</span>
{variant !== 'compact' && (
<span className="sponsor-visit">
{t('pages.sponsors.visit')} <ExportOutlined />
</span>
)}
</a>
{onClose && (
<button
type="button"
className="sponsor-close"
aria-label={t('close')}
title={t('close')}
onClick={onClose}
>
<CloseOutlined />
</button>
)}
</div>
);
}
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
import {
dismissSponsor,
isSponsorDismissed,
sponsorsForSlot,
type SponsorSlot as Slot,
} from '@/lib/sponsors';
import SponsorCard, { type SponsorCardVariant } from './SponsorCard';
const ROTATE_MS = 30_000;
interface SponsorSlotProps {
slot: Slot;
variant?: SponsorCardVariant;
iconOnly?: boolean;
rotate?: boolean;
className?: string;
}
export default function SponsorSlot({
slot,
variant = 'banner',
iconOnly,
rotate,
className,
}: SponsorSlotProps) {
const { data } = useSponsorsQuery();
const [, setDismissTick] = useState(0);
const [index, setIndex] = useState(0);
// Re-filtered each render so a close (dismissTick bump) re-reads localStorage.
const visible = sponsorsForSlot(data.sponsors, slot).filter(
(s) => !isSponsorDismissed(s.id, slot),
);
useEffect(() => {
if (!rotate || visible.length < 2) return;
const timer = window.setInterval(() => setIndex((i) => i + 1), ROTATE_MS);
return () => window.clearInterval(timer);
}, [rotate, visible.length]);
if (visible.length === 0) return null;
const sponsor = visible[(rotate ? index : 0) % visible.length];
return (
<div className={className}>
<SponsorCard
sponsor={sponsor}
variant={variant}
iconOnly={iconOnly}
onClose={() => {
dismissSponsor(sponsor.id, slot);
setDismissTick((n) => n + 1);
}}
/>
</div>
);
}