feat(sponsors): drop the sidebar slot and simplify the Sponsors page

The sidebar no longer carries a sponsor card. The backend stops serving
the "sidebar" slot, so a sponsors.json entry that still lists it keeps
only its other slots and one listing nothing else is dropped. Its
rotation, three-sponsor cap and collapsed-rail logo rendering go with it.

The Sponsors page now lists only real sponsors: the "Your brand here"
card shows only as the empty state instead of sitting in the grid and
repeating the header's Become a sponsor button, and the Available
placements section and its slot-occupancy helper are removed. Cards are
larger, two per row on wide screens. Unused i18n keys are dropped from
every locale.
This commit is contained in:
MHSanaei
2026-10-13 23:32:56 +02:00
parent 319e693276
commit 6f1dd0d757
25 changed files with 68 additions and 545 deletions
+24 -15
View File
@@ -184,28 +184,37 @@
.sponsor-card-card .sponsor-main {
flex-direction: column;
align-items: flex-start;
padding: 16px;
gap: 16px;
padding: 24px;
}
.sponsor-card-card .sponsor-logo {
width: 56px;
height: 56px;
width: 72px;
height: 72px;
border-radius: 12px;
}
.sponsor-card-card .sponsor-logo-fallback {
font-size: 28px;
}
.sponsor-card-card .sponsor-body {
gap: 6px;
}
.sponsor-card-card .sponsor-title {
font-size: 17px;
line-height: 1.4;
}
.sponsor-card-card .sponsor-text {
font-size: 14px;
line-height: 1.6;
}
.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;
font-size: 14px;
}
@media (max-width: 576px) {
@@ -7,7 +7,7 @@ import SponsorCard from './SponsorCard';
const sponsor: Sponsor = {
id: 'acme-2026-10',
name: 'Acme VPS',
slots: ['dashboard', 'sidebar', 'page', 'login'],
slots: ['dashboard', '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.' },
@@ -57,9 +57,8 @@ const meta = {
argTypes: {
sponsor: { description: 'Sponsor entry from GET /sponsors.' },
variant: {
description: 'banner (dashboard), compact (sidebar/login) or card (Sponsors page).',
description: 'banner (dashboard), compact (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 },
@@ -81,7 +80,7 @@ export const Banner: Story = {
export const Compact: Story = {
args: { variant: 'compact', onClose: () => {} },
render: (args) => (
<div style={{ width: 204 }}>
<div style={{ width: 334 }}>
<SponsorCard {...args} />
</div>
),
@@ -108,23 +107,6 @@ export const BannerLongCopy: Story = {
},
};
export const SidebarLongCopy: Story = {
args: { variant: 'compact', sponsor: longSponsor, onClose: () => {} },
render: (args) => (
<div style={{ width: 204 }}>
<SponsorCard {...args} />
</div>
),
play: async ({ canvasElement }) => {
await expectNotClipped(canvasElement, '.sponsor-title');
await expectInsideCard(canvasElement, '.sponsor-text');
await expect(part(canvasElement, '.sponsor-main')).toHaveAttribute(
'title',
`${longSponsor.title.en}\n${longSponsor.text.en}`,
);
},
};
export const LoginLongCopy: Story = {
args: { variant: 'compact', sponsor: longSponsor, onClose: () => {} },
render: (args) => (
@@ -135,6 +117,10 @@ export const LoginLongCopy: Story = {
play: async ({ canvasElement }) => {
await expectNotClipped(canvasElement, '.sponsor-title');
await expectNotClipped(canvasElement, '.sponsor-text');
await expect(part(canvasElement, '.sponsor-main')).toHaveAttribute(
'title',
`${longSponsor.title.en}\n${longSponsor.text.en}`,
);
},
};
@@ -151,7 +137,3 @@ export const PageLongCopy: Story = {
await expectNotClipped(canvasElement, '.sponsor-title');
},
};
export const IconOnly: Story = {
args: { iconOnly: true },
};
@@ -12,7 +12,6 @@ export type SponsorCardVariant = 'banner' | 'compact' | 'card';
export interface SponsorCardProps {
sponsor: Sponsor;
variant?: SponsorCardVariant;
iconOnly?: boolean;
onClose?: () => void;
}
@@ -36,33 +35,13 @@ function SponsorLogo({ sponsor }: { sponsor: Sponsor }) {
);
}
export default function SponsorCard({
sponsor,
variant = 'banner',
iconOnly = false,
onClose,
}: SponsorCardProps) {
export default function SponsorCard({ sponsor, variant = 'banner', 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
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
import {
@@ -9,47 +9,29 @@ import {
} 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) {
export default function SponsorSlot({ slot, variant = 'banner', 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];
const sponsor = visible[0];
return (
<div className={className}>
<SponsorCard
sponsor={sponsor}
variant={variant}
iconOnly={iconOnly}
onClose={() => {
dismissSponsor(sponsor.id, slot);
setDismissTick((n) => n + 1);
-4
View File
@@ -247,10 +247,6 @@
padding: 8px 8px 12px;
}
.sider-sponsor {
margin-bottom: 6px;
}
.sidebar-pin {
display: inline-flex;
align-items: center;
-9
View File
@@ -44,7 +44,6 @@ 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/';
@@ -452,13 +451,6 @@ 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>
@@ -544,7 +536,6 @@ export default function AppSidebar() {
}}
/>
<div className="drawer-footer">
<SponsorSlot slot="sidebar" variant="compact" rotate className="sider-sponsor" />
<VersionBadge version={panelVersion} />
</div>
</Drawer>
+1 -23
View File
@@ -1,6 +1,6 @@
import type { Sponsor } from '@/generated/types';
export type SponsorSlot = 'dashboard' | 'sidebar' | 'page' | 'login';
export type SponsorSlot = 'dashboard' | 'page' | 'login';
const DISMISS_KEY = 'xui.sponsor.dismissed';
export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
@@ -38,25 +38,3 @@ export function dismissSponsor(id: string, slot: SponsorSlot, now = Date.now())
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] };
}
+2 -70
View File
@@ -29,30 +29,14 @@
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;
align-items: center;
gap: 8px;
height: 100%;
min-height: 170px;
padding: 16px;
padding: 40px 24px;
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);
}
@@ -82,58 +66,6 @@
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;
+7 -90
View File
@@ -1,22 +1,13 @@
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 { Button, Col, ConfigProvider, Layout, Row, Spin, Typography } from 'antd';
import { CrownOutlined, 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 { sponsorsForSlot } from '@/lib/sponsors';
import './SponsorsPage.css';
function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
@@ -36,10 +27,10 @@ function BecomeButton({ contact, block }: { contact?: string; block?: boolean })
);
}
function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) {
function YourBrandCard({ contact }: { contact?: string }) {
const { t } = useTranslation();
return (
<div className={`sponsors-yourbrand${large ? ' is-large' : ''}`}>
<div className="sponsors-yourbrand">
<span className="sponsors-yourbrand-icon">
<PlusOutlined />
</span>
@@ -56,34 +47,6 @@ export default function SponsorsPage() {
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');
@@ -110,63 +73,17 @@ export default function SponsorsPage() {
<Spin spinning={!fetched} delay={200}>
{sponsors.length === 0 ? (
fetched && <YourBrandCard contact={data.contact} large />
fetched && <YourBrandCard contact={data.contact} />
) : (
<Row gutter={[16, 16]}>
{sponsors.map((sponsor) => (
<Col key={sponsor.id} xs={24} sm={12} xl={8}>
<Col key={sponsor.id} xs={24} lg={12}>
<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>
+5 -56
View File
@@ -1,12 +1,6 @@
import { afterEach, describe, expect, test } from 'vitest';
import {
SPONSOR_DISMISS_MS,
dismissSponsor,
isSponsorDismissed,
pickLocale,
placementStatus,
} from '@/lib/sponsors';
import { SPONSOR_DISMISS_MS, dismissSponsor, isSponsorDismissed, pickLocale } from '@/lib/sponsors';
afterEach(() => {
localStorage.clear();
@@ -33,10 +27,10 @@ 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);
dismissSponsor('acme', 'login', t0);
expect(isSponsorDismissed('acme', 'login', t0 + SPONSOR_DISMISS_MS - 1)).toBe(true);
expect(isSponsorDismissed('acme', 'dashboard', t0)).toBe(false);
expect(isSponsorDismissed('acme', 'sidebar', t0 + SPONSOR_DISMISS_MS)).toBe(false);
expect(isSponsorDismissed('acme', 'login', t0 + SPONSOR_DISMISS_MS)).toBe(false);
});
test('prunes expired entries on the next dismiss', () => {
@@ -48,51 +42,6 @@ describe('sponsor dismissal', () => {
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,
});
expect(isSponsorDismissed('acme', 'login', t0)).toBe(false);
});
});
+3 -10
View File
@@ -45,7 +45,6 @@ const (
sponsorsErrTTL = 10 * time.Minute
maxSponsorsBytes = 256 << 10
maxLogoBytes = 256 << 10
maxSidebarSlots = 3
localSponsorsDir = "../sponsors/3X"
sponsorLogoPath = "/sponsors/logo/"
)
@@ -67,7 +66,7 @@ var (
// The panel proxies logos from sponsorLogoBase: CSP stays 'self' and no third party sees admin IPs.
sponsorLogoRe = regexp.MustCompile(`^[A-Za-z0-9_-][A-Za-z0-9._-]*\.(png|webp|jpg)$`)
sponsorSlots = map[string]bool{"dashboard": true, "sidebar": true, "page": true, "login": true}
sponsorSlots = map[string]bool{"dashboard": true, "page": true, "login": true}
sponsorsMu sync.Mutex
sponsorsRaw *SponsorList
@@ -217,7 +216,6 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
if strings.HasPrefix(raw.Contact, "https://") {
out.Contact = raw.Contact
}
sidebarTaken := 0
for _, sp := range raw.Sponsors {
// A missing enable counts as on, so a forgotten field never hides a paid slot.
disabled := sp.Enable != nil && !*sp.Enable
@@ -233,18 +231,13 @@ func activeSponsors(raw *SponsorList, now time.Time) *SponsorList {
}
slots := make([]string, 0, len(sp.Slots))
for _, slot := range sp.Slots {
// The sidebar rotates, so capping it keeps each paid card on screen long enough.
if !sponsorSlots[slot] || (slot == "sidebar" && sidebarTaken >= maxSidebarSlots) {
continue
if sponsorSlots[slot] {
slots = append(slots, slot)
}
slots = append(slots, slot)
}
if len(slots) == 0 {
continue
}
if slices.Contains(slots, "sidebar") {
sidebarTaken++
}
sp.Slots = slots
out.Sponsors = append(out.Sponsors, sp)
}
+2 -31
View File
@@ -6,7 +6,6 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"sync/atomic"
"testing"
@@ -22,7 +21,7 @@ func validSponsor() Sponsor {
return Sponsor{
ID: "acme",
Name: "Acme",
Slots: []string{"dashboard", "sidebar"},
Slots: []string{"dashboard"},
Until: sponsorTestNow.Add(24 * time.Hour),
Logo: "acme.png",
Link: "https://acme.example/",
@@ -59,34 +58,6 @@ func TestActiveSponsorsFilters(t *testing.T) {
}
}
func TestActiveSponsorsCapsSidebarAtThree(t *testing.T) {
raw := &SponsorList{}
for _, id := range []string{"expired", "a", "b", "c", "d", "e"} {
sp := validSponsor()
sp.ID = id
sp.Slots = []string{"sidebar", "page"}
if id == "expired" {
sp.Until = sponsorTestNow
}
if id == "e" {
sp.Slots = []string{"sidebar"}
}
raw.Sponsors = append(raw.Sponsors, sp)
}
got := activeSponsors(raw, sponsorTestNow)
want := map[string][]string{
"a": {"sidebar", "page"}, "b": {"sidebar", "page"}, "c": {"sidebar", "page"}, "d": {"page"},
}
if len(got.Sponsors) != len(want) {
t.Fatalf("got %d sponsors, want %d (e has only sidebar and must drop)", len(got.Sponsors), len(want))
}
for _, sp := range got.Sponsors {
if !slices.Equal(sp.Slots, want[sp.ID]) {
t.Errorf("%s slots = %v, want %v", sp.ID, sp.Slots, want[sp.ID])
}
}
}
func TestActiveSponsorsLogoName(t *testing.T) {
cases := []struct{ logo, want string }{
{"acme.png", "/sponsors/logo/acme.png"},
@@ -114,7 +85,7 @@ func TestActiveSponsorsLogoName(t *testing.T) {
func TestActiveSponsorsResolvesLogoAndSlots(t *testing.T) {
sp := validSponsor()
sp.Slots = []string{"subpage", "login"}
sp.Slots = []string{"subpage", "sidebar", "login"}
got := activeSponsors(&SponsorList{Contact: "javascript:x", Sponsors: []Sponsor{sp}}, sponsorTestNow)
if len(got.Sponsors) != 1 {
t.Fatalf("got %d sponsors, want 1", len(got.Sponsors))
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "راعٍ",
"visit": "زيارة",
"yourBrand": "علامتك التجارية هنا",
"yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم.",
"placements": "المواضع المتاحة",
"placementDashboard": "لافتة لوحة المعلومات",
"placementDashboardDesc": "موضع مميز أعلى لوحة المعلومات، راعٍ واحد في كل مرة.",
"placementSidebar": "بطاقة الشريط الجانبي",
"placementSidebarDesc": "تظهر في كل صفحات اللوحة وتتناوب بين الرعاة.",
"placementLogin": "صفحة تسجيل الدخول",
"placementLoginDesc": "يراها المسؤول عند كل تسجيل دخول.",
"placementPage": "صفحة الرعاة",
"placementPageDesc": "مدرجة هنا مع الشعار والوصف والرابط.",
"available": "متاح",
"takenUntil": "محجوز حتى {date}",
"activeCount": "{count} نشط"
"yourBrandText": "ضع شركتك أمام آلاف مسؤولي الخوادم الذين يفتحون 3X-UI كل يوم."
}
},
"tgbot": {
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "Sponsor",
"visit": "Visit",
"yourBrand": "Your brand here",
"yourBrandText": "Put your company in front of thousands of server admins who open 3X-UI every day.",
"placements": "Available placements",
"placementDashboard": "Overview banner",
"placementDashboardDesc": "Premium spot at the top of the dashboard, one sponsor at a time.",
"placementSidebar": "Sidebar card",
"placementSidebarDesc": "Shown on every panel page, rotating between sponsors.",
"placementLogin": "Login page",
"placementLoginDesc": "Seen by the admin at every sign-in.",
"placementPage": "Sponsors page",
"placementPageDesc": "Listed here with logo, description and link.",
"available": "Available",
"takenUntil": "Taken until {date}",
"activeCount": "{count} active"
"yourBrandText": "Put your company in front of thousands of server admins who open 3X-UI every day."
}
},
"tgbot": {
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "Patrocinador",
"visit": "Visitar",
"yourBrand": "Tu marca aquí",
"yourBrandText": "Muestra tu empresa a miles de administradores de servidores que abren 3X-UI cada día.",
"placements": "Espacios disponibles",
"placementDashboard": "Banner del panel",
"placementDashboardDesc": "Espacio premium en la parte superior del panel, un patrocinador a la vez.",
"placementSidebar": "Tarjeta lateral",
"placementSidebarDesc": "Se muestra en todas las páginas del panel, rotando entre patrocinadores.",
"placementLogin": "Página de inicio de sesión",
"placementLoginDesc": "La ve el administrador en cada inicio de sesión.",
"placementPage": "Página de patrocinadores",
"placementPageDesc": "Aparece aquí con logo, descripción y enlace.",
"available": "Disponible",
"takenUntil": "Ocupado hasta {date}",
"activeCount": "{count} activos"
"yourBrandText": "Muestra tu empresa a miles de administradores de servidores que abren 3X-UI cada día."
}
},
"tgbot": {
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "اسپانسر",
"visit": "مشاهده",
"yourBrand": "جای برند شما",
"yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز می‌کنند.",
"placements": "جایگاه‌های موجود",
"placementDashboard": "بنر داشبورد",
"placementDashboardDesc": "جایگاه ویژه بالای صفحه‌ی داشبورد، هر بار فقط یک اسپانسر.",
"placementSidebar": "کارت سایدبار",
"placementSidebarDesc": "در همه‌ی صفحات پنل نمایش داده می‌شود و بین اسپانسرها چرخش دارد.",
"placementLogin": "صفحه‌ی ورود",
"placementLoginDesc": "ادمین در هر بار ورود آن را می‌بیند.",
"placementPage": "صفحه‌ی اسپانسرها",
"placementPageDesc": "همراه با لوگو، توضیحات و لینک در همین صفحه.",
"available": "خالی",
"takenUntil": "پر شده تا {date}",
"activeCount": "{count} فعال"
"yourBrandText": "شرکت خود را جلوی چشم هزاران ادمین سروری بگذارید که هر روز 3X-UI را باز می‌کنند."
}
},
"tgbot": {
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "Sponsor",
"visit": "Kunjungi",
"yourBrand": "Merek Anda di sini",
"yourBrandText": "Tampilkan perusahaan Anda kepada ribuan admin server yang membuka 3X-UI setiap hari.",
"placements": "Slot yang tersedia",
"placementDashboard": "Banner dasbor",
"placementDashboardDesc": "Tempat premium di atas dasbor, satu sponsor dalam satu waktu.",
"placementSidebar": "Kartu sidebar",
"placementSidebarDesc": "Tampil di setiap halaman panel, bergantian antar sponsor.",
"placementLogin": "Halaman login",
"placementLoginDesc": "Dilihat admin setiap kali masuk.",
"placementPage": "Halaman sponsor",
"placementPageDesc": "Tercantum di sini dengan logo, deskripsi, dan tautan.",
"available": "Tersedia",
"takenUntil": "Terisi hingga {date}",
"activeCount": "{count} aktif"
"yourBrandText": "Tampilkan perusahaan Anda kepada ribuan admin server yang membuka 3X-UI setiap hari."
}
},
"tgbot": {
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "スポンサー",
"visit": "訪問",
"yourBrand": "ここにあなたのブランドを",
"yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。",
"placements": "掲載枠",
"placementDashboard": "ダッシュボードバナー",
"placementDashboardDesc": "ダッシュボード上部のプレミアム枠。一度に 1 社のみ。",
"placementSidebar": "サイドバーカード",
"placementSidebarDesc": "パネルの全ページに表示され、スポンサー間でローテーションします。",
"placementLogin": "ログインページ",
"placementLoginDesc": "管理者がログインするたびに表示されます。",
"placementPage": "スポンサーページ",
"placementPageDesc": "ロゴ、説明、リンク付きでここに掲載されます。",
"available": "空きあり",
"takenUntil": "{date} まで予約済み",
"activeCount": "{count} 件掲載中"
"yourBrandText": "毎日 3X-UI を開く何千人ものサーバー管理者に御社をアピールできます。"
}
},
"tgbot": {
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "Patrocinador",
"visit": "Visitar",
"yourBrand": "Sua marca aqui",
"yourBrandText": "Coloque sua empresa diante de milhares de administradores de servidores que abrem o 3X-UI todos os dias.",
"placements": "Espaços disponíveis",
"placementDashboard": "Banner do painel",
"placementDashboardDesc": "Espaço premium no topo do painel, um patrocinador por vez.",
"placementSidebar": "Cartão lateral",
"placementSidebarDesc": "Exibido em todas as páginas do painel, alternando entre patrocinadores.",
"placementLogin": "Página de login",
"placementLoginDesc": "Visto pelo administrador a cada login.",
"placementPage": "Página de patrocinadores",
"placementPageDesc": "Listado aqui com logo, descrição e link.",
"available": "Disponível",
"takenUntil": "Ocupado até {date}",
"activeCount": "{count} ativos"
"yourBrandText": "Coloque sua empresa diante de milhares de administradores de servidores que abrem o 3X-UI todos os dias."
}
},
"tgbot": {
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "Спонсор",
"visit": "Перейти",
"yourBrand": "Здесь может быть ваш бренд",
"yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI.",
"placements": "Доступные места",
"placementDashboard": "Баннер на главной",
"placementDashboardDesc": "Премиум-место вверху панели, один спонсор за раз.",
"placementSidebar": "Карточка в боковой панели",
"placementSidebarDesc": "Показывается на всех страницах панели, спонсоры чередуются.",
"placementLogin": "Страница входа",
"placementLoginDesc": "Администратор видит её при каждом входе.",
"placementPage": "Страница спонсоров",
"placementPageDesc": "Размещение здесь с логотипом, описанием и ссылкой.",
"available": "Свободно",
"takenUntil": "Занято до {date}",
"activeCount": "Активных: {count}"
"yourBrandText": "Покажите свою компанию тысячам администраторов серверов, которые каждый день открывают 3X-UI."
}
},
"tgbot": {
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "Sponsor",
"visit": "Ziyaret et",
"yourBrand": "Markanız burada",
"yourBrandText": "Şirketinizi her gün 3X-UI açan binlerce sunucu yöneticisinin önüne çıkarın.",
"placements": "Mevcut yerleşimler",
"placementDashboard": "Genel bakış afişi",
"placementDashboardDesc": "Panonun üstünde premium alan, aynı anda tek sponsor.",
"placementSidebar": "Kenar çubuğu kartı",
"placementSidebarDesc": "Tüm panel sayfalarında gösterilir, sponsorlar arasında döner.",
"placementLogin": "Giriş sayfası",
"placementLoginDesc": "Yönetici her girişte görür.",
"placementPage": "Sponsorlar sayfası",
"placementPageDesc": "Logo, açıklama ve bağlantıyla burada listelenir.",
"available": "Müsait",
"takenUntil": "{date} tarihine kadar dolu",
"activeCount": "{count} aktif"
"yourBrandText": "Şirketinizi her gün 3X-UI açan binlerce sunucu yöneticisinin önüne çıkarın."
}
},
"tgbot": {
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "Спонсор",
"visit": "Перейти",
"yourBrand": "Тут може бути ваш бренд",
"yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI.",
"placements": "Доступні місця",
"placementDashboard": "Банер на головній",
"placementDashboardDesc": "Преміум-місце вгорі панелі, один спонсор за раз.",
"placementSidebar": "Картка в бічній панелі",
"placementSidebarDesc": "Показується на всіх сторінках панелі, спонсори чергуються.",
"placementLogin": "Сторінка входу",
"placementLoginDesc": "Адміністратор бачить її під час кожного входу.",
"placementPage": "Сторінка спонсорів",
"placementPageDesc": "Розміщення тут із логотипом, описом і посиланням.",
"available": "Вільно",
"takenUntil": "Зайнято до {date}",
"activeCount": "Активних: {count}"
"yourBrandText": "Покажіть свою компанію тисячам адміністраторів серверів, які щодня відкривають 3X-UI."
}
},
"tgbot": {
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "Tài trợ",
"visit": "Truy cập",
"yourBrand": "Thương hiệu của bạn ở đây",
"yourBrandText": "Đưa công ty bạn đến hàng nghìn quản trị viên máy chủ mở 3X-UI mỗi ngày.",
"placements": "Vị trí hiện có",
"placementDashboard": "Banner tổng quan",
"placementDashboardDesc": "Vị trí cao cấp ở đầu bảng điều khiển, mỗi lần một nhà tài trợ.",
"placementSidebar": "Thẻ thanh bên",
"placementSidebarDesc": "Hiển thị trên mọi trang của panel, luân phiên giữa các nhà tài trợ.",
"placementLogin": "Trang đăng nhập",
"placementLoginDesc": "Quản trị viên thấy mỗi lần đăng nhập.",
"placementPage": "Trang nhà tài trợ",
"placementPageDesc": "Được liệt kê tại đây với logo, mô tả và liên kết.",
"available": "Còn trống",
"takenUntil": "Đã đặt đến {date}",
"activeCount": "{count} đang hoạt động"
"yourBrandText": "Đưa công ty bạn đến hàng nghìn quản trị viên máy chủ mở 3X-UI mỗi ngày."
}
},
"tgbot": {
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "赞助",
"visit": "访问",
"yourBrand": "您的品牌展示位",
"yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。",
"placements": "可用展示位",
"placementDashboard": "概览横幅",
"placementDashboardDesc": "仪表盘顶部的高级位置,每次仅一家赞助商。",
"placementSidebar": "侧边栏卡片",
"placementSidebarDesc": "在面板所有页面显示,并在赞助商之间轮换。",
"placementLogin": "登录页",
"placementLoginDesc": "管理员每次登录时都能看到。",
"placementPage": "赞助商页面",
"placementPageDesc": "在此页面展示徽标、简介和链接。",
"available": "可预订",
"takenUntil": "已预订至 {date}",
"activeCount": "{count} 个进行中"
"yourBrandText": "让每天打开 3X-UI 的数千名服务器管理员看到您的公司。"
}
},
"tgbot": {
+1 -13
View File
@@ -2423,19 +2423,7 @@
"tag": "贊助",
"visit": "造訪",
"yourBrand": "您的品牌展示位",
"yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。",
"placements": "可用展示位",
"placementDashboard": "總覽橫幅",
"placementDashboardDesc": "儀表板頂部的高級位置,每次僅一家贊助商。",
"placementSidebar": "側邊欄卡片",
"placementSidebarDesc": "在面板所有頁面顯示,並在贊助商之間輪換。",
"placementLogin": "登入頁",
"placementLoginDesc": "管理員每次登入時都能看到。",
"placementPage": "贊助商頁面",
"placementPageDesc": "在此頁面展示標誌、簡介與連結。",
"available": "可預訂",
"takenUntil": "已預訂至 {date}",
"activeCount": "{count} 個進行中"
"yourBrandText": "讓每天打開 3X-UI 的數千名伺服器管理員看到您的公司。"
}
},
"tgbot": {