mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-15 18:35:00 +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:
@@ -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] };
|
||||
}
|
||||
Reference in New Issue
Block a user