mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
feat(website): add the Stalker portal setup guide with mock-backed screenshots
Publish "How to Connect a Stalker or Ministra Portal to IPTVnator": the portal URL shapes discovery accepts, MAC normalization, the optional serial/device-ID/signature fields and the pinning rules behind the "generate device IDs" toggle, what endpoint discovery does on Add, the sections a portal source gets, Account info, and a troubleshooting list built from the app's own refusal messages, plus a seven-question FAQ. The guide is cross-linked from the download pages and llms.txt. Guide screenshots come from the capture script. Shots that walk into a Stalker portal start the stalker-mock-server and seed its marketing-demo portal for that run only, so release shots never gain a third source card. The frame guard allowlists exactly that scenario's MAC and keeps rejecting every other MAC-shaped string. To keep the live-TV frame free of third-party images, the fictional live channel list and the channel-logo SVG renderer move into @iptvnator/shared/marketing-fixtures; both mocks now serve /assets/marketing/logo/<slug>.svg, the Stalker marketing-demo scenario builds its ITV categories, channels and schedule from those fixtures instead of faker names with picsum logos, and the mock resolves asset URLs on get_all_channels too, which is the response the app renders the channel list from. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
1 parent
9455e0db65
commit
6cfdaf5900
31 files changed
+938
-193
No files matched your search
@@ -1 +1,2 @@
|
||||
export * from './lib/shared-marketing-fixtures';
|
||||
export * from './lib/marketing-live-fixtures';
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
MARKETING_LIVE_CATEGORIES,
|
||||
MARKETING_LIVE_CHANNELS,
|
||||
marketingSlug,
|
||||
marketingTitleFromSlug,
|
||||
renderMarketingLogoSvg,
|
||||
} from './marketing-live-fixtures';
|
||||
|
||||
describe('marketing live fixtures', () => {
|
||||
it('places every channel in a declared category with a schedule', () => {
|
||||
const keys = new Set(MARKETING_LIVE_CATEGORIES.map((c) => c.key));
|
||||
|
||||
for (const channel of MARKETING_LIVE_CHANNELS) {
|
||||
expect(keys.has(channel.categoryKey)).toBe(true);
|
||||
expect(channel.epgTitles.length).toBeGreaterThan(0);
|
||||
}
|
||||
expect(new Set(MARKETING_LIVE_CHANNELS.map((c) => c.name)).size).toBe(
|
||||
MARKETING_LIVE_CHANNELS.length
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips a channel name through the slug used in asset URLs', () => {
|
||||
expect(marketingSlug('Aurora News')).toBe('aurora-news');
|
||||
expect(marketingTitleFromSlug('aurora-news.svg')).toBe('Aurora News');
|
||||
});
|
||||
|
||||
it('renders a self-contained SVG logo with the channel initials', () => {
|
||||
const svg = renderMarketingLogoSvg('aurora-news.svg', '128x128');
|
||||
|
||||
expect(svg.startsWith('<svg xmlns="http://www.w3.org/2000/svg"')).toBe(
|
||||
true
|
||||
);
|
||||
expect(svg).toContain('width="128"');
|
||||
expect(svg).toContain('>AN</text>');
|
||||
expect(svg).not.toContain('http://localhost');
|
||||
});
|
||||
|
||||
it('escapes markup that leaks into a slug', () => {
|
||||
expect(renderMarketingLogoSvg('<b>x', undefined)).not.toContain('<b>');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Provider-neutral fictional live TV fixtures shared by the Xtream and
|
||||
* Stalker marketing mocks, plus the generated logo artwork both serve from
|
||||
* `/assets/marketing/logo/<slug>.svg`. Everything here is invented: no real
|
||||
* broadcaster, programme, or logo is referenced, which is what makes the
|
||||
* screenshots built on top of it publishable.
|
||||
*/
|
||||
|
||||
export type MarketingLiveCategoryKey =
|
||||
| 'newsroom'
|
||||
| 'sports'
|
||||
| 'family'
|
||||
| 'culture';
|
||||
|
||||
export interface MarketingLiveCategoryFixture {
|
||||
key: MarketingLiveCategoryKey;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface MarketingLiveChannelFixture {
|
||||
categoryKey: MarketingLiveCategoryKey;
|
||||
name: string;
|
||||
/** Cycled through the generated schedule, so the guide always has titles. */
|
||||
epgTitles: readonly string[];
|
||||
}
|
||||
|
||||
export const MARKETING_LIVE_CATEGORIES: readonly MarketingLiveCategoryFixture[] =
|
||||
[
|
||||
{ key: 'newsroom', name: 'Newsroom' },
|
||||
{ key: 'sports', name: 'Sports & Motion' },
|
||||
{ key: 'family', name: 'Family Channels' },
|
||||
{ key: 'culture', name: 'Culture & Docs' },
|
||||
];
|
||||
|
||||
export const MARKETING_LIVE_CHANNELS: readonly MarketingLiveChannelFixture[] = [
|
||||
{
|
||||
categoryKey: 'newsroom',
|
||||
name: 'Aurora News',
|
||||
epgTitles: ['Morning Briefing', 'City Desk', 'Global Window'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'newsroom',
|
||||
name: 'Civic One',
|
||||
epgTitles: ['Town Hall Live', 'Policy Today', 'Open Forum'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'sports',
|
||||
name: 'Fieldside Sports',
|
||||
epgTitles: ['Training Ground', 'Matchday Studio', 'Final Whistle'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'sports',
|
||||
name: 'Motion Arena',
|
||||
epgTitles: ['Court Vision', 'Trackside', 'Night Highlights'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'family',
|
||||
name: 'Horizon Kids',
|
||||
epgTitles: ['Rocket Workshop', 'Tiny Explorers', 'Story Lantern'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'family',
|
||||
name: 'Kitchen Lab',
|
||||
epgTitles: ['Breakfast Builders', 'Family Table', 'Sweet Science'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'culture',
|
||||
name: 'Atlas Docs',
|
||||
epgTitles: ['Ocean Notes', 'Museum Hour', 'Wide Angle'],
|
||||
},
|
||||
{
|
||||
categoryKey: 'culture',
|
||||
name: 'Night Music',
|
||||
epgTitles: ['Studio Session', 'Late Set', 'Ambient City'],
|
||||
},
|
||||
];
|
||||
|
||||
export const MARKETING_LOGO_ASSET_PATH = '/assets/marketing/logo';
|
||||
|
||||
/** URL-safe slug shared by asset URLs and the renderer that answers them. */
|
||||
export function marketingSlug(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
}
|
||||
|
||||
/** Inverse of `marketingSlug` for rendering initials back out of a URL. */
|
||||
export function marketingTitleFromSlug(slug: string): string {
|
||||
return slug
|
||||
.replace(/\.svg$/i, '')
|
||||
.split('-')
|
||||
.filter(Boolean)
|
||||
.map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function marketingHash(value: string): number {
|
||||
return value.split('').reduce((acc, char) => {
|
||||
return (acc * 31 + char.charCodeAt(0)) >>> 0;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function marketingPalette(seed: string): [string, string, string] {
|
||||
const palettes: Array<[string, string, string]> = [
|
||||
['#0b1026', '#1b6b77', '#f2a65a'],
|
||||
['#15111f', '#6b3fa0', '#20c7b5'],
|
||||
['#071b2c', '#2457a6', '#f05d5e'],
|
||||
['#1b1b24', '#8d4f2a', '#e9c46a'],
|
||||
['#10251d', '#2a9d8f', '#e76f51'],
|
||||
['#18151f', '#b23a48', '#f4a261'],
|
||||
];
|
||||
return palettes[marketingHash(seed) % palettes.length];
|
||||
}
|
||||
|
||||
export function marketingSvgDocument(
|
||||
width: number,
|
||||
height: number,
|
||||
body: string
|
||||
): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img">${body}</svg>`;
|
||||
}
|
||||
|
||||
export function escapeMarketingSvg(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/** `WIDTHxHEIGHT` query value; falls back to a square 256 px logo. */
|
||||
export function parseMarketingLogoSize(size: string | undefined): {
|
||||
width: number;
|
||||
height: number;
|
||||
} {
|
||||
const match = size?.match(/^(\d+)x(\d+)$/);
|
||||
if (!match) {
|
||||
return { width: 256, height: 256 };
|
||||
}
|
||||
return { width: Number(match[1]), height: Number(match[2]) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Channel logo: gradient tile with the channel initials. Identical output for
|
||||
* both mocks so a screenshot looks the same whichever portal type it shows.
|
||||
*/
|
||||
export function renderMarketingLogoSvg(
|
||||
slug: string,
|
||||
size: string | undefined
|
||||
): string {
|
||||
const { width, height } = parseMarketingLogoSize(size);
|
||||
const title = marketingTitleFromSlug(slug);
|
||||
const palette = marketingPalette(`logo:${slug}`);
|
||||
const initials = title
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.slice(0, 2)
|
||||
.map((part) => part[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
|
||||
return marketingSvgDocument(
|
||||
width,
|
||||
height,
|
||||
`
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" x2="1" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="${palette[0]}" />
|
||||
<stop offset="100%" stop-color="${palette[1]}" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" rx="${width * 0.22}" fill="url(#bg)" />
|
||||
<circle cx="${width * 0.72}" cy="${height * 0.25}" r="${width * 0.2}" fill="${palette[2]}" opacity="0.35" />
|
||||
<path d="M ${width * 0.16} ${height * 0.72} C ${width * 0.35} ${height * 0.48}, ${width * 0.58} ${height * 0.92}, ${width * 0.84} ${height * 0.58}" fill="none" stroke="#fff" stroke-width="${Math.max(8, width * 0.05)}" stroke-linecap="round" opacity="0.42" />
|
||||
<text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Arial, Helvetica, sans-serif" font-size="${width * 0.3}" font-weight="800" fill="#fff">${escapeMarketingSvg(initials)}</text>
|
||||
`
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user