mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-14 09:55:01 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user