mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-01 19:52:09 +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:
@@ -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,4 +1,5 @@
|
||||
export const keys = {
|
||||
sponsors: () => ['sponsors'] as const,
|
||||
server: {
|
||||
status: () => ['server', 'status'] as const,
|
||||
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1019,6 +1019,37 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"key": "",
|
||||
"value": ""
|
||||
},
|
||||
"Sponsor": {
|
||||
"enable": true,
|
||||
"id": "acme-2026-10",
|
||||
"link": "https://acme.example/?utm_source=3x-ui",
|
||||
"logo": "/sponsors/logo/acme.png",
|
||||
"name": "Acme VPS",
|
||||
"slots": [
|
||||
""
|
||||
],
|
||||
"text": {},
|
||||
"title": {},
|
||||
"until": "2026-11-01T00:00:00Z"
|
||||
},
|
||||
"SponsorList": {
|
||||
"contact": "https://t.me/example",
|
||||
"sponsors": [
|
||||
{
|
||||
"enable": true,
|
||||
"id": "acme-2026-10",
|
||||
"link": "https://acme.example/?utm_source=3x-ui",
|
||||
"logo": "/sponsors/logo/acme.png",
|
||||
"name": "Acme VPS",
|
||||
"slots": [
|
||||
""
|
||||
],
|
||||
"text": {},
|
||||
"title": {},
|
||||
"until": "2026-11-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
"SubBalancer": {
|
||||
"createdAt": 1710000000000,
|
||||
"enabled": true,
|
||||
|
||||
@@ -4109,6 +4109,84 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Sponsor": {
|
||||
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||
"properties": {
|
||||
"enable": {
|
||||
"example": true,
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"example": "acme-2026-10",
|
||||
"type": "string"
|
||||
},
|
||||
"link": {
|
||||
"example": "https://acme.example/?utm_source=3x-ui",
|
||||
"type": "string"
|
||||
},
|
||||
"logo": {
|
||||
"example": "/sponsors/logo/acme.png",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"example": "Acme VPS",
|
||||
"type": "string"
|
||||
},
|
||||
"slots": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"text": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"title": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"until": {
|
||||
"example": "2026-11-01T00:00:00Z",
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"link",
|
||||
"name",
|
||||
"slots",
|
||||
"text",
|
||||
"title",
|
||||
"until"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SponsorList": {
|
||||
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
|
||||
"properties": {
|
||||
"contact": {
|
||||
"example": "https://t.me/example",
|
||||
"type": "string"
|
||||
},
|
||||
"sponsors": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Sponsor"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sponsors"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubBalancer": {
|
||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||
"properties": {
|
||||
|
||||
@@ -939,6 +939,23 @@ export interface Setting {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Sponsor {
|
||||
enable?: boolean | null;
|
||||
id: string;
|
||||
link: string;
|
||||
logo?: string;
|
||||
name: string;
|
||||
slots: string[];
|
||||
text: Record<string, string>;
|
||||
title: Record<string, string>;
|
||||
until: string;
|
||||
}
|
||||
|
||||
export interface SponsorList {
|
||||
contact?: string;
|
||||
sponsors: Sponsor[];
|
||||
}
|
||||
|
||||
export interface SubBalancer {
|
||||
createdAt: number;
|
||||
enabled: boolean;
|
||||
|
||||
@@ -998,6 +998,25 @@ export const SettingSchema = z.object({
|
||||
});
|
||||
export type Setting = z.infer<typeof SettingSchema>;
|
||||
|
||||
export const SponsorSchema = z.object({
|
||||
enable: z.boolean().nullable().optional(),
|
||||
id: z.string(),
|
||||
link: z.string(),
|
||||
logo: z.string().optional(),
|
||||
name: z.string(),
|
||||
slots: z.array(z.string()),
|
||||
text: z.record(z.string(), z.string()),
|
||||
title: z.record(z.string(), z.string()),
|
||||
until: z.string(),
|
||||
});
|
||||
export type Sponsor = z.infer<typeof SponsorSchema>;
|
||||
|
||||
export const SponsorListSchema = z.object({
|
||||
contact: z.string().optional(),
|
||||
sponsors: z.array(z.lazy(() => SponsorSchema)),
|
||||
});
|
||||
export type SponsorList = z.infer<typeof SponsorListSchema>;
|
||||
|
||||
export const SubBalancerSchema = z.object({
|
||||
createdAt: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
|
||||
@@ -14,6 +14,7 @@ const TITLE_KEYS: Record<string, string> = {
|
||||
'/outbound': 'menu.outbounds',
|
||||
'/routing': 'menu.routing',
|
||||
'/api-docs': 'menu.apiDocs',
|
||||
'/sponsors': 'menu.sponsors',
|
||||
};
|
||||
|
||||
export function usePageTitle() {
|
||||
|
||||
@@ -247,6 +247,10 @@
|
||||
padding: 8px 8px 12px;
|
||||
}
|
||||
|
||||
.sider-sponsor {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sidebar-pin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
CodeOutlined,
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DiscordOutlined,
|
||||
@@ -43,6 +44,7 @@ import { formatPanelVersion } from '@/lib/panel-version';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
||||
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||
import './AppSidebar.css';
|
||||
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
@@ -68,6 +70,7 @@ type IconName =
|
||||
| 'cluster'
|
||||
| 'hosts'
|
||||
| 'logout'
|
||||
| 'sponsors'
|
||||
| 'apidocs'
|
||||
| 'outbound'
|
||||
| 'routing';
|
||||
@@ -82,6 +85,7 @@ const iconByName: Record<IconName, ComponentType> = {
|
||||
cluster: ClusterOutlined,
|
||||
hosts: GlobalOutlined,
|
||||
logout: LogoutOutlined,
|
||||
sponsors: CrownOutlined,
|
||||
apidocs: ApiOutlined,
|
||||
outbound: ExportOutlined,
|
||||
routing: SwapOutlined,
|
||||
@@ -232,6 +236,7 @@ export default function AppSidebar() {
|
||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: '/sponsors', icon: 'sponsors', title: t('menu.sponsors') },
|
||||
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
||||
],
|
||||
[t],
|
||||
@@ -447,6 +452,13 @@ export default function AppSidebar() {
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
<div className="sider-footer">
|
||||
<SponsorSlot
|
||||
slot="sidebar"
|
||||
variant="compact"
|
||||
iconOnly={railCollapsed}
|
||||
rotate
|
||||
className="sider-sponsor"
|
||||
/>
|
||||
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
||||
</div>
|
||||
</Layout.Sider>
|
||||
@@ -532,6 +544,7 @@ export default function AppSidebar() {
|
||||
}}
|
||||
/>
|
||||
<div className="drawer-footer">
|
||||
<SponsorSlot slot="sidebar" variant="compact" rotate className="sider-sponsor" />
|
||||
<VersionBadge version={panelVersion} />
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Sponsor } from '@/generated/types';
|
||||
|
||||
export type SponsorSlot = 'dashboard' | 'sidebar' | 'page' | 'login';
|
||||
|
||||
const DISMISS_KEY = 'xui.sponsor.dismissed';
|
||||
export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function pickLocale(map: Record<string, string> | undefined, lang: string): string {
|
||||
if (!map) return '';
|
||||
const short = lang.split('-')[0].toLowerCase();
|
||||
return map[lang] || map[short] || map.en || '';
|
||||
}
|
||||
|
||||
export function sponsorsForSlot(sponsors: Sponsor[], slot: SponsorSlot): Sponsor[] {
|
||||
return sponsors.filter((s) => s.slots.includes(slot));
|
||||
}
|
||||
|
||||
function readDismissed(): Record<string, number> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(localStorage.getItem(DISMISS_KEY) || '{}');
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, number>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function isSponsorDismissed(id: string, slot: SponsorSlot, now = Date.now()): boolean {
|
||||
const at = readDismissed()[`${id}:${slot}`];
|
||||
return typeof at === 'number' && now - at < SPONSOR_DISMISS_MS;
|
||||
}
|
||||
|
||||
export function dismissSponsor(id: string, slot: SponsorSlot, now = Date.now()) {
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(readDismissed()).filter(([, at]) => now - at < SPONSOR_DISMISS_MS),
|
||||
);
|
||||
next[`${id}:${slot}`] = now;
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Mirrors the backend: dashboard/login show one sponsor, the sidebar rotates up to three.
|
||||
export const SLOT_CAPACITY: Partial<Record<SponsorSlot, number>> = {
|
||||
dashboard: 1,
|
||||
login: 1,
|
||||
sidebar: 3,
|
||||
};
|
||||
|
||||
export interface PlacementStatus {
|
||||
count: number;
|
||||
capacity?: number;
|
||||
takenUntil?: string;
|
||||
}
|
||||
|
||||
// When full, a place frees up once enough bookings end to drop below capacity.
|
||||
export function placementStatus(sponsors: Sponsor[], slot: SponsorSlot): PlacementStatus {
|
||||
const booked = sponsorsForSlot(sponsors, slot);
|
||||
const capacity = SLOT_CAPACITY[slot];
|
||||
if (!capacity || booked.length < capacity) return { count: booked.length, capacity };
|
||||
const ends = booked.map((s) => s.until).sort((a, b) => Date.parse(a) - Date.parse(b));
|
||||
return { count: booked.length, capacity, takenUntil: ends[booked.length - capacity] };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ const HostsPage = lazy(() => import('@/pages/hosts/HostsPage'));
|
||||
const SettingsPage = lazy(() => import('@/pages/settings/SettingsPage'));
|
||||
const XrayPage = lazy(() => import('@/pages/xray/XrayPage'));
|
||||
const ApiDocsPage = lazy(() => import('@/pages/api-docs/ApiDocsPage'));
|
||||
const SponsorsPage = lazy(() => import('@/pages/sponsors/SponsorsPage'));
|
||||
|
||||
function withSuspense(node: React.ReactNode) {
|
||||
return (
|
||||
@@ -51,6 +52,7 @@ const routes: RouteObject[] = [
|
||||
{ path: 'outbound', element: withSuspense(<XrayPage />) },
|
||||
{ path: 'routing', element: withSuspense(<XrayPage />) },
|
||||
{ path: 'api-docs', element: withSuspense(<ApiDocsPage />) },
|
||||
{ path: 'sponsors', element: withSuspense(<SponsorsPage />) },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
.settings-page,
|
||||
.nodes-page,
|
||||
.groups-page,
|
||||
.api-docs-page {
|
||||
.api-docs-page,
|
||||
.sponsors-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
min-height: 100vh;
|
||||
@@ -19,7 +20,8 @@
|
||||
.settings-page.is-dark,
|
||||
.nodes-page.is-dark,
|
||||
.groups-page.is-dark,
|
||||
.api-docs-page.is-dark {
|
||||
.api-docs-page.is-dark,
|
||||
.sponsors-page.is-dark {
|
||||
--bg-page: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
}
|
||||
@@ -31,7 +33,8 @@
|
||||
.settings-page.is-dark.is-ultra,
|
||||
.nodes-page.is-dark.is-ultra,
|
||||
.groups-page.is-dark.is-ultra,
|
||||
.api-docs-page.is-dark.is-ultra {
|
||||
.api-docs-page.is-dark.is-ultra,
|
||||
.sponsors-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { afterEach, describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
SPONSOR_DISMISS_MS,
|
||||
dismissSponsor,
|
||||
isSponsorDismissed,
|
||||
pickLocale,
|
||||
placementStatus,
|
||||
} from '@/lib/sponsors';
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe('pickLocale', () => {
|
||||
const map = { en: 'Hello', fa: 'سلام', 'zh-TW': '哈囉' };
|
||||
|
||||
test.each([
|
||||
['fa-IR', 'سلام'],
|
||||
['zh-TW', '哈囉'],
|
||||
['zh-CN', 'Hello'],
|
||||
['ru-RU', 'Hello'],
|
||||
])('%s resolves to %s', (lang, want) => {
|
||||
expect(pickLocale(map, lang)).toBe(want);
|
||||
});
|
||||
|
||||
test('missing map yields empty string', () => {
|
||||
expect(pickLocale(undefined, 'en-US')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sponsor dismissal', () => {
|
||||
const t0 = 1_800_000_000_000;
|
||||
|
||||
test('hides only the dismissed slot until the window elapses', () => {
|
||||
dismissSponsor('acme', 'sidebar', t0);
|
||||
expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
|
||||
expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false);
|
||||
expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS)).toBe(false);
|
||||
});
|
||||
|
||||
test('prunes expired entries on the next dismiss', () => {
|
||||
dismissSponsor('old', 'login', t0);
|
||||
dismissSponsor('new', 'login', t0 + SPONSOR_DISMISS_MS);
|
||||
const stored = JSON.parse(localStorage.getItem('xui.sponsor.dismissed') || '{}');
|
||||
expect(Object.keys(stored)).toEqual(['new:login']);
|
||||
});
|
||||
|
||||
test('corrupt storage is treated as nothing dismissed', () => {
|
||||
localStorage.setItem('xui.sponsor.dismissed', 'not json');
|
||||
expect(isSponsorDismissed('acme', 'sidebar', t0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('placementStatus', () => {
|
||||
const sp = (id: string, slots: string[], until: string) => ({
|
||||
id,
|
||||
name: id,
|
||||
slots,
|
||||
until,
|
||||
title: {},
|
||||
text: {},
|
||||
link: 'https://x.example/',
|
||||
});
|
||||
|
||||
test('exclusive slot is taken until the latest booking ends', () => {
|
||||
const sponsors = [
|
||||
sp('a', ['dashboard'], '2026-11-01T00:00:00Z'),
|
||||
sp('b', ['dashboard'], '2026-12-15T00:00:00Z'),
|
||||
];
|
||||
expect(placementStatus(sponsors, 'dashboard')).toEqual({
|
||||
count: 2,
|
||||
capacity: 1,
|
||||
takenUntil: '2026-12-15T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
test('sidebar is full at three and frees up when the earliest ends', () => {
|
||||
const sponsors = [
|
||||
sp('a', ['sidebar'], '2026-12-01T00:00:00Z'),
|
||||
sp('b', ['sidebar'], '2026-10-20T00:00:00Z'),
|
||||
sp('c', ['sidebar'], '2026-11-10T00:00:00Z'),
|
||||
];
|
||||
expect(placementStatus(sponsors, 'sidebar')).toEqual({
|
||||
count: 3,
|
||||
capacity: 3,
|
||||
takenUntil: '2026-10-20T00:00:00Z',
|
||||
});
|
||||
expect(placementStatus(sponsors.slice(0, 2), 'sidebar')).toEqual({ count: 2, capacity: 3 });
|
||||
});
|
||||
|
||||
test('uncapped page slot reports only a count', () => {
|
||||
expect(placementStatus([sp('a', ['page'], '2026-12-01T00:00:00Z')], 'page')).toEqual({
|
||||
count: 1,
|
||||
capacity: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user