mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-03 20:53:41 +03:00
fd7b3559bc
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.
63 lines
2.2 KiB
TypeScript
63 lines
2.2 KiB
TypeScript
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] };
|
|
}
|