Proyecto App Linux

This commit is contained in:
2026-05-03 23:43:09 +02:00
commit 7482733843
1248 changed files with 189750 additions and 0 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

@@ -0,0 +1,84 @@
---
interface Props {
title: string;
description: string;
image?: string;
type?: 'website' | 'article';
noindex?: boolean;
keywords?: string[];
publishedTime?: string;
modifiedTime?: string;
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
}
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const {
title,
description,
image = '/iptvnator/og-image.png',
type = 'website',
noindex = false,
keywords = [],
publishedTime,
modifiedTime,
jsonLd,
} = Astro.props;
const absoluteImageURL = new URL(image, Astro.site);
const robotsContent = noindex
? 'noindex, nofollow, noarchive'
: 'index, follow, max-image-preview:large, max-snippet:-1, max-video-preview:-1';
const schemaPayload = jsonLd
? JSON.stringify(Array.isArray(jsonLd) ? jsonLd : [jsonLd])
: null;
---
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/iptvnator/favicon.ico" />
<meta name="generator" content={Astro.generator} />
<meta name="robots" content={robotsContent} />
<meta name="referrer" content="strict-origin-when-cross-origin" />
<meta name="author" content="4gray" />
<meta name="theme-color" content="#0a0a08" />
<meta name="format-detection" content="telephone=no" />
{keywords.length > 0 && <meta name="keywords" content={keywords.join(', ')} />}
<!-- Fonts: Playfair Display (display), DM Sans (body), IBM Plex Mono (code) -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,500;0,600;0,700;0,800;1,400;1,500;1,600;1,700&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=IBM+Plex+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<!-- Canonical URL -->
<link rel="canonical" href={canonicalURL.toString()} />
<!-- Primary Meta Tags -->
<title>{title}</title>
<meta name="title" content={title} />
<meta name="description" content={description} />
<!-- Open Graph / Facebook -->
<meta property="og:type" content={type} />
<meta property="og:site_name" content="IPTVnator" />
<meta property="og:locale" content="en_US" />
<meta property="og:url" content={canonicalURL.toString()} />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:image" content={absoluteImageURL.toString()} />
<meta property="og:image:secure_url" content={absoluteImageURL.toString()} />
{type === 'article' && publishedTime && <meta property="article:published_time" content={publishedTime} />}
{type === 'article' && modifiedTime && <meta property="article:modified_time" content={modifiedTime} />}
<!-- Twitter -->
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content={canonicalURL.toString()} />
<meta property="twitter:title" content={title} />
<meta property="twitter:description" content={description} />
<meta property="twitter:image" content={absoluteImageURL.toString()} />
{schemaPayload && (
<script type="application/ld+json" set:html={schemaPayload} />
)}
@@ -0,0 +1,71 @@
---
interface Props {
title: string;
description: string;
pubDate: Date;
slug: string;
tags?: string[];
heroImage?: string;
}
const { title, description, pubDate, slug, tags = [], heroImage } = Astro.props;
const formattedDate = pubDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
---
<a
href={`/iptvnator/blog/${slug}/`}
class="card-dotted group block"
>
{heroImage && (
<div class="overflow-hidden rounded-t-lg -mx-6 -mt-6 mb-5">
<img
src={heroImage}
alt=""
class="aspect-video w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
loading="lazy"
/>
</div>
)}
<div class="flex items-center gap-2">
<time datetime={pubDate.toISOString()} class="font-mono text-xs text-surface-500">
{formattedDate}
</time>
{
tags.length > 0 && (
<>
<span class="text-surface-700">&middot;</span>
{tags.map((tag) => (
<span class="rounded-full border border-dashed border-accent-500/30 px-2 py-0.5 text-xs text-accent-400">
{tag}
</span>
))}
</>
)
}
</div>
<h3
class="mt-3 font-display text-lg font-semibold text-surface-100 transition-colors duration-200 group-hover:text-accent-400"
>
{title}
</h3>
<p class="mt-2 text-sm leading-relaxed text-surface-500">
{description}
</p>
<span class="mt-4 inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.1em] text-accent-500 transition-colors group-hover:text-accent-400">
Read more
<svg
class="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</span>
</a>
@@ -0,0 +1,122 @@
---
const platforms = [
{
name: 'macOS',
icon: 'M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.8-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z',
href: 'https://github.com/4gray/iptvnator/releases/latest',
},
{
name: 'Windows',
icon: 'M3 12V6.75l8-1.25V12H3zm0 .5h8v6.5l-8-1.25V12.5zM11.5 5.35L21 3.75V12h-9.5V5.35zm0 7.15H21v8.25l-9.5-1.6V12.5z',
href: 'https://github.com/4gray/iptvnator/releases/latest',
},
{
name: 'Linux',
icon: 'M12.504 0c-.155 0-.315.008-.48.021-4.226.333-3.105 4.807-3.17 6.298-.076 1.092-.3 1.953-1.05 3.02-.885 1.051-2.127 2.75-2.716 4.521-.278.832-.41 1.684-.287 2.489a.424.424 0 00-.11.135c-.26.268-.45.6-.663.839-.199.199-.485.267-.797.4-.313.136-.658.269-.864.68-.09.189-.136.394-.132.602 0 .199.027.4.055.536.058.399.116.728.04.97-.249.68-.28 1.145-.106 1.484.174.334.535.47.94.601.81.2 1.91.135 2.774.6.926.466 1.866.67 2.616.47.526-.116.97-.464 1.208-.946.587-.003 1.23-.269 2.26-.334.699-.058 1.574.267 2.577.2.025.134.063.198.114.333l.003.003c.391.778 1.113 1.368 1.884 1.43.39.03.8-.066 1.109-.199.69-.3 1.286-1.065 1.09-1.745a21.03 21.03 0 01-.174-.606c-.101-.427-.2-.855-.29-1.283-.09-.428.04-.546.481-.735.463-.199.833-.338 1.073-.668.24-.33.364-.8.269-1.437-.14-.98-.689-2.072-1.329-3.069-.645-1-.34-1.665-.34-2.863.006-1.497-.898-2.987-1.792-4.048-.89-1.06-1.78-1.835-2.085-2.748-.228-.68-.098-1.401.128-2.056.112-.322.254-.631.325-.933.07-.303.072-.622-.058-.853C13.56.199 13.04 0 12.504 0z',
href: 'https://github.com/4gray/iptvnator/releases/latest',
},
];
const packageManagers = [
{ label: 'Homebrew', command: 'brew install --cask iptvnator' },
{ label: 'Snap', command: 'sudo snap install iptvnator' },
];
---
<section id="download" class="relative overflow-hidden py-28">
<hr class="section-divider mb-28" />
<!-- Ambient glow -->
<div class="pointer-events-none absolute bottom-0 left-1/2 h-[400px] w-[600px] -translate-x-1/2 rounded-full bg-accent-500/[0.04] blur-[100px]"></div>
<div class="relative mx-auto max-w-6xl px-6">
<!-- Centered editorial header -->
<div class="mx-auto max-w-lg text-center">
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">
Get started
</span>
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
Get <em class="font-medium text-accent-400" style="font-style: italic;">IPTVnator</em>
</h2>
<p class="mt-4 text-lg text-surface-400">
Download for your platform or use it directly in the browser.
</p>
</div>
<!-- Platform buttons -->
<div class="mt-14 flex flex-wrap justify-center gap-4">
{
platforms.map((platform) => (
<a
href={platform.href}
class="card-dotted group flex items-center gap-4 px-8 py-5"
>
<svg class="h-7 w-7 text-surface-500 transition-colors duration-200 group-hover:text-accent-400" fill="currentColor" viewBox="0 0 24 24">
<path d={platform.icon} />
</svg>
<div class="text-left">
<div class="font-mono text-[10px] uppercase tracking-[0.15em] text-surface-500">Download for</div>
<div class="mt-0.5 font-semibold text-surface-100">{platform.name}</div>
</div>
</a>
))
}
</div>
<!-- Package managers -->
<div class="mx-auto mt-10 max-w-xl space-y-3">
{
packageManagers.map((pm) => (
<div class="flex items-center gap-4 rounded-lg border border-dashed border-surface-700/60 bg-surface-900/50 px-5 py-3.5">
<span class="w-20 shrink-0 font-mono text-[10px] font-medium uppercase tracking-[0.15em] text-surface-500">
{pm.label}
</span>
<code class="flex-1 font-mono text-sm text-accent-400/80">
{pm.command}
</code>
<button
class="copy-btn shrink-0 rounded-md p-1.5 text-surface-600 transition-colors hover:text-surface-300"
data-command={pm.command}
aria-label={`Copy ${pm.label} command`}
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />
</svg>
</button>
</div>
))
}
</div>
<!-- Web app link -->
<div class="mt-10 text-center">
<p class="text-sm text-surface-500">
Or use it directly at{' '}
<a
href="https://iptvnator.vercel.app"
class="text-accent-400 underline decoration-accent-500/30 underline-offset-4 transition-colors hover:text-accent-300 hover:decoration-accent-400/50"
>
iptvnator.vercel.app
</a>
</p>
</div>
</div>
</section>
<script>
document.querySelectorAll('.copy-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const command = (btn as HTMLElement).dataset.command;
if (command) {
await navigator.clipboard.writeText(command);
const svg = btn.querySelector('svg');
if (svg) {
svg.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />';
setTimeout(() => {
svg.innerHTML = '<path stroke-linecap="round" stroke-linejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9.75a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 011.927-.184" />';
}, 1500);
}
}
});
});
</script>
@@ -0,0 +1,223 @@
---
const heroFeatures = [
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />',
title: 'M3U / M3U8 Playlists',
description: 'Import playlists from files or URLs with full M3U and M3U8 support.',
},
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />',
title: 'Xtream Codes API',
description: 'Connect to Xtream Codes providers with live TV, VOD, and series.',
},
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />',
title: 'Electronic Program Guide',
description: 'XMLTV-based EPG with current and upcoming program information.',
},
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /><path stroke-linecap="round" stroke-linejoin="round" d="M12 18v3" />',
title: 'Cross-Platform',
description: 'Runs on macOS, Windows, Linux as a desktop app or PWA in any browser.',
},
];
const carouselFeatures = [
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.858 15.355-5.858 21.213 0" />',
title: 'Stalker Portals',
description: 'Browse and stream content from Stalker/Ministra portal providers.',
},
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z" />',
title: 'Favorites & History',
description: 'Mark channels as favorites and keep track of recently watched.',
},
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />',
title: 'Search & Filter',
description: 'Quickly find channels across playlists with full-text search.',
},
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
title: 'Multi-Language',
description: 'Available in 16 languages with community-contributed translations.',
},
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />',
title: 'External Players',
description: 'Open streams in MPV, VLC, or other external players from the app.',
},
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />',
title: 'Light & Dark Themes',
description: 'Choose between light and dark themes for comfortable viewing.',
},
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z" />',
title: 'Remote Control',
description: 'Control the player from your phone with the built-in remote.',
},
{
icon: '<path stroke-linecap="round" stroke-linejoin="round" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />',
title: 'Privacy First',
description: 'No tracking, no analytics. Your data stays on your device.',
},
];
---
<section id="features" class="relative py-28">
<hr class="section-divider mb-28" />
<div class="relative z-10 mx-auto max-w-6xl px-6">
<!-- Editorial header: left-aligned -->
<div class="max-w-xl">
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">
Features
</span>
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
Everything <em class="font-medium text-accent-400" style="font-style: italic;">you need</em>
</h2>
<p class="mt-4 text-lg text-surface-400">
A full-featured IPTV player packed with the tools for the best viewing experience.
</p>
</div>
<!-- Hero feature cards: 2x2 grid -->
<div class="stagger-children mt-16 grid grid-cols-1 gap-5 sm:grid-cols-2" data-stagger>
{
heroFeatures.map((feature, i) => (
<div class="feature-card group relative overflow-hidden rounded-lg border border-dashed border-surface-600/60 bg-gradient-to-br from-surface-900/80 via-surface-950/60 to-surface-900/40 p-7 transition-all duration-500 hover:border-accent-500/40 hover:shadow-[0_0_30px_rgba(32,168,168,0.08)]">
<!-- Background watermark icon — large, rotated, partially clipped -->
<div class={`feature-watermark pointer-events-none absolute -bottom-6 -right-4 opacity-[0.04] transition-all duration-700 ease-out group-hover:opacity-[0.1] ${i % 2 === 0 ? 'watermark-left' : 'watermark-right'}`}>
<svg class="h-36 w-36 text-accent-400 sm:h-44 sm:w-44" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="0.7">
<Fragment set:html={feature.icon} />
</svg>
</div>
<!-- Watermark gradient mask — fades icon into card bg from top-left -->
<div class="pointer-events-none absolute inset-0 bg-gradient-to-br from-surface-900/95 via-surface-950/40 to-transparent" />
<!-- CRT scanline overlay on hover -->
<div class="pointer-events-none absolute inset-0 z-10 opacity-0 transition-opacity duration-500 group-hover:opacity-100" style="background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(32,168,168,0.015) 2px, rgba(32,168,168,0.015) 4px);" />
<!-- Card content -->
<div class="relative z-[1]">
<!-- Mono label + accent line -->
<div class="mb-5 flex items-center gap-3">
<span class="font-mono text-[10px] font-medium uppercase tracking-[0.2em] text-accent-500/70 transition-colors duration-300 group-hover:text-accent-400">0{i + 1}</span>
<span class="h-px flex-1 bg-gradient-to-r from-accent-500/20 to-transparent transition-colors duration-300 group-hover:from-accent-500/40" />
</div>
<h3 class="font-display text-xl font-bold tracking-tight text-surface-100 transition-colors duration-300 group-hover:text-surface-50 sm:text-2xl">
{feature.title}
</h3>
<p class="mt-2.5 max-w-[85%] text-sm leading-relaxed text-surface-500 transition-colors duration-300 group-hover:text-surface-400">
{feature.description}
</p>
</div>
</div>
))
}
</div>
<!-- Sub-header between hero and carousel -->
<div class="mt-16">
<span class="font-mono text-xs uppercase tracking-[0.15em] text-surface-500">
And more
</span>
</div>
</div>
<!-- Carousel: full-width infinite marquee -->
<div class="relative mt-6 overflow-hidden opacity-0" data-carousel>
<!-- Gradient masks: left and right fade -->
<div class="pointer-events-none absolute inset-y-0 left-0 z-10 w-24 bg-gradient-to-r from-surface-950 to-transparent sm:w-32" />
<div class="pointer-events-none absolute inset-y-0 right-0 z-10 w-24 bg-gradient-to-l from-surface-950 to-transparent sm:w-32" />
<!-- Marquee track -->
<div class="carousel-track flex w-max gap-4 py-4" aria-label="Additional features">
<!-- First set of cards -->
{
carouselFeatures.map((feature, i) => (
<div class="group relative w-64 shrink-0 overflow-hidden rounded-lg border border-dashed border-surface-600/60 bg-gradient-to-b from-surface-900/60 to-surface-950/40 p-5 transition-all duration-500 hover:border-accent-500/40 hover:shadow-[0_0_24px_rgba(32,168,168,0.06)]">
<div class="relative mb-3.5 inline-block">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-accent-500/[0.08] ring-1 ring-accent-500/20 transition-all duration-500 group-hover:bg-accent-500/[0.14] group-hover:ring-accent-500/35 group-hover:shadow-[0_0_14px_rgba(32,168,168,0.12)]">
<svg class="h-5 w-5 text-accent-400 transition-all duration-500 group-hover:text-accent-300 group-hover:drop-shadow-[0_0_4px_rgba(32,168,168,0.4)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<Fragment set:html={feature.icon} />
</svg>
</div>
<div class="absolute -right-0.5 -top-0.5 h-1 w-1 rounded-full bg-accent-500/50 transition-colors duration-300 group-hover:bg-accent-400" />
</div>
<h3 class="font-display text-sm font-semibold tracking-tight text-surface-100 transition-colors duration-300 group-hover:text-surface-50">{feature.title}</h3>
<p class="mt-1.5 text-xs leading-relaxed text-surface-500 transition-colors duration-300 group-hover:text-surface-400">
{feature.description}
</p>
</div>
))
}
<!-- Duplicate set for seamless loop -->
{
carouselFeatures.map((feature, i) => (
<div class="group relative w-64 shrink-0 overflow-hidden rounded-lg border border-dashed border-surface-600/60 bg-gradient-to-b from-surface-900/60 to-surface-950/40 p-5 transition-all duration-500 hover:border-accent-500/40 hover:shadow-[0_0_24px_rgba(32,168,168,0.06)]" aria-hidden="true">
<div class="relative mb-3.5 inline-block">
<div class="flex h-10 w-10 items-center justify-center rounded-lg bg-accent-500/[0.08] ring-1 ring-accent-500/20 transition-all duration-500 group-hover:bg-accent-500/[0.14] group-hover:ring-accent-500/35 group-hover:shadow-[0_0_14px_rgba(32,168,168,0.12)]">
<svg class="h-5 w-5 text-accent-400 transition-all duration-500 group-hover:text-accent-300 group-hover:drop-shadow-[0_0_4px_rgba(32,168,168,0.4)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<Fragment set:html={feature.icon} />
</svg>
</div>
<div class="absolute -right-0.5 -top-0.5 h-1 w-1 rounded-full bg-accent-500/50 transition-colors duration-300 group-hover:bg-accent-400" />
</div>
<h3 class="font-display text-sm font-semibold tracking-tight text-surface-100 transition-colors duration-300 group-hover:text-surface-50">{feature.title}</h3>
<p class="mt-1.5 text-xs leading-relaxed text-surface-500 transition-colors duration-300 group-hover:text-surface-400">
{feature.description}
</p>
</div>
))
}
</div>
</div>
</section>
<script>
// Stagger animation for hero grid on scroll
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('stagger-visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
document.querySelectorAll('[data-stagger]').forEach((el) => observer.observe(el));
// Carousel fade-in on scroll
const carouselEl = document.querySelector('[data-carousel]');
if (carouselEl) {
const co = new IntersectionObserver((entries) => {
entries.forEach((e) => {
if (e.isIntersecting) {
e.target.classList.remove('opacity-0');
e.target.classList.add('animate-fade-in');
co.unobserve(e.target);
}
});
}, { threshold: 0.05 });
co.observe(carouselEl);
}
</script>
<style>
.watermark-left {
transform: rotate(-12deg) scale(1);
}
.watermark-right {
transform: rotate(12deg) scale(1);
}
.feature-card:hover .watermark-left {
transform: rotate(-10deg) scale(1.12);
}
.feature-card:hover .watermark-right {
transform: rotate(10deg) scale(1.12);
}
</style>
@@ -0,0 +1,87 @@
---
interface Props {
title: string;
description: string;
pubDate: Date;
slug: string;
tags?: string[];
heroImage?: string;
}
const { title, description, pubDate, slug, tags = [], heroImage } = Astro.props;
const formattedDate = pubDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
});
---
<a
href={`/iptvnator/blog/${slug}/`}
class="card-dotted group block overflow-hidden"
>
<div class="grid items-center gap-8 md:grid-cols-2">
<!-- Thumbnail -->
<div class="overflow-hidden rounded-lg -mx-6 -mt-6 md:-my-6 md:-ml-6 md:mr-0">
{heroImage ? (
<img
src={heroImage}
alt=""
class="aspect-video w-full object-cover transition-transform duration-500 group-hover:scale-[1.03]"
loading="lazy"
/>
) : (
<div class="flex aspect-video w-full items-center justify-center bg-gradient-to-br from-surface-800 to-surface-900">
<img
src="/iptvnator/favicon.ico"
alt=""
class="h-12 w-12 opacity-20"
/>
</div>
)}
</div>
<!-- Content -->
<div class="py-2">
<div class="flex items-center gap-2">
<span class="font-mono text-[10px] uppercase tracking-[0.15em] text-accent-500">Featured</span>
<span class="text-surface-700">&middot;</span>
<time datetime={pubDate.toISOString()} class="font-mono text-xs text-surface-500">
{formattedDate}
</time>
</div>
{tags.length > 0 && (
<div class="mt-2 flex flex-wrap gap-1.5">
{tags.map((tag) => (
<span class="rounded-full border border-dashed border-accent-500/30 px-2 py-0.5 text-xs text-accent-400">
{tag}
</span>
))}
</div>
)}
<h3 class="mt-4 font-display text-2xl font-bold tracking-tight text-surface-50 transition-colors duration-200 group-hover:text-accent-400 sm:text-3xl">
{title}
</h3>
<p class="mt-3 text-sm leading-relaxed text-surface-400">
{description}
</p>
<span class="mt-5 inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.1em] text-accent-500 transition-colors group-hover:text-accent-400">
Read more
<svg
class="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-1"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</span>
</div>
</div>
</a>
+114
View File
@@ -0,0 +1,114 @@
---
import footerWatermark from '../assets/footer-watermark.png';
const socialLinks = [
{
label: 'GitHub',
href: 'https://github.com/4gray/iptvnator',
icon: 'M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z',
},
{
label: 'Telegram',
href: 'https://t.me/iptvnator',
icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69.01-.03.01-.14-.07-.2-.08-.06-.19-.04-.27-.02-.12.03-1.99 1.27-5.62 3.72-.53.36-1.01.54-1.44.53-.47-.01-1.38-.27-2.06-.49-.83-.27-1.49-.42-1.43-.88.03-.24.37-.49 1.02-.74 3.99-1.74 6.65-2.89 7.99-3.44 3.81-1.58 4.6-1.86 5.12-1.87.11 0 .37.03.54.17.14.12.18.28.2.45-.01.06.01.24 0 .38z',
},
{
label: 'Bluesky',
href: 'https://bsky.app/profile/4gray.bsky.social',
icon: 'M13.202 10.857c2.752 2.066 5.711 6.254 6.798 8.502 1.087-2.248 4.046-6.436 6.798-8.502 1.985-1.49 5.202-2.644 5.202 1.026 0 .733-.42 6.157-.667 7.038-.857 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.197-7.279-1.304-7.847-2.97-.104-.305-.153-.448-.153-.327 0-.121-.049.021-.153.327-.567 1.666-2.782 8.166-7.847 2.97-2.667-2.736-1.432-5.473 3.422-6.299-2.777.473-5.899-.308-6.755-3.37-.246-.881-.667-6.305-.667-7.038 0-3.67 3.217-2.517 5.202-1.026Z',
},
{
label: 'Sponsor',
href: 'https://ko-fi.com/4gray',
icon: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
},
];
---
<footer class="relative overflow-hidden border-t border-dashed border-surface-700/60">
<div class="pointer-events-none absolute inset-0">
<div class="absolute -bottom-32 -right-16 h-[28rem] w-[28rem] rounded-full bg-accent-500/[0.07] blur-3xl"></div>
<div class="absolute -bottom-24 right-2 h-[23rem] w-[23rem]">
<img
src={footerWatermark.src}
alt=""
aria-hidden="true"
class="footer-watermark h-full w-full object-contain opacity-[0.075] grayscale"
loading="lazy"
/>
</div>
</div>
<div class="relative z-10 mx-auto max-w-6xl px-6 py-16">
<div class="flex flex-col items-center gap-10 md:flex-row md:justify-between">
<!-- Logo & description -->
<div class="text-center md:text-left">
<div class="flex items-center justify-center gap-2.5 md:justify-start">
<img
src="/iptvnator/favicon.ico"
alt="IPTVnator logo"
class="h-6 w-6"
/>
<span class="font-display text-lg font-semibold text-surface-100">IPTVnator</span>
</div>
<p class="mt-2 text-sm text-surface-500">
Open-source IPTV player for everyone.
</p>
</div>
<!-- Social links -->
<div class="flex items-center gap-2">
{
socialLinks.map((link) => (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="rounded-lg border border-transparent p-2.5 text-surface-600 transition-all duration-200 hover:border-surface-700/60 hover:text-accent-400"
aria-label={link.label}
>
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24">
<path d={link.icon} />
</svg>
</a>
))
}
</div>
</div>
<div class="mt-10 flex flex-col items-center justify-between gap-4 border-t border-dashed border-surface-700/60 pt-8 md:flex-row">
<p class="font-mono text-[11px] text-surface-600">
MIT License. Built with <a href="https://astro.build" class="text-surface-500 transition-colors hover:text-accent-400">Astro</a>.
</p>
<p class="font-mono text-[11px] text-surface-600">
IPTVnator does not provide any playlists or content.
</p>
</div>
</div>
</footer>
<style>
.footer-watermark {
transform: rotate(-14deg) scale(1.06);
filter: drop-shadow(0 18px 30px rgba(0, 0, 0, 0.45));
mix-blend-mode: screen;
animation: footerMarkFloat 11s ease-in-out infinite;
}
@keyframes footerMarkFloat {
0%, 100% {
transform: rotate(-14deg) translate3d(0, 0, 0) scale(1.06);
opacity: 0.065;
}
50% {
transform: rotate(-11deg) translate3d(-12px, -10px, 0) scale(1.1);
opacity: 0.09;
}
}
@media (prefers-reduced-motion: reduce) {
.footer-watermark {
animation: none;
}
}
</style>
+147
View File
@@ -0,0 +1,147 @@
---
const navLinks = [
{ label: 'Features', href: '/iptvnator/#features' },
{ label: 'Screenshots', href: '/iptvnator/#screenshots' },
{ label: 'Download', href: '/iptvnator/#download' },
{ label: 'Blog', href: '/iptvnator/blog/' },
];
---
<header
id="site-header"
class="fixed top-0 z-50 w-full transition-all duration-500"
>
<div class="absolute inset-0 border-b border-surface-700/0 bg-surface-950/0 backdrop-blur-none transition-all duration-500" id="header-bg"></div>
<nav class="relative mx-auto flex max-w-6xl items-center justify-between px-6 py-5">
<!-- Logo -->
<a href="/iptvnator/" class="flex items-center gap-3 group">
<img
src="/iptvnator/favicon.ico"
alt="IPTVnator logo"
class="h-7 w-7 transition-transform duration-300 group-hover:scale-110"
/>
<span class="font-display text-xl font-semibold tracking-tight text-surface-50">
IPTVnator
</span>
</a>
<!-- Desktop nav -->
<div class="hidden items-center gap-8 md:flex">
{
navLinks.map((link) => (
<a
href={link.href}
class="text-[13px] font-medium uppercase tracking-[0.12em] text-surface-400 transition-colors duration-200 hover:text-accent-400"
>
{link.label}
</a>
))
}
</div>
<!-- Right side actions -->
<div class="hidden items-center gap-4 md:flex">
<a
href="https://github.com/4gray/iptvnator"
target="_blank"
rel="noopener noreferrer"
class="text-surface-500 transition-colors hover:text-surface-200"
aria-label="GitHub"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
</a>
<a
href="https://github.com/4gray/iptvnator/releases/latest"
class="rounded-lg border border-accent-500/30 bg-accent-500/10 px-4 py-2 text-[13px] font-medium text-accent-400 transition-all duration-200 hover:border-accent-400/50 hover:bg-accent-500/20 hover:text-accent-300"
>
Download
</a>
</div>
<!-- Mobile menu button -->
<button
id="mobile-menu-btn"
class="inline-flex items-center justify-center rounded-lg p-2 text-surface-400 transition-colors hover:text-surface-100 md:hidden"
aria-label="Toggle menu"
>
<svg id="menu-icon-open" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 12h16M4 18h16" />
</svg>
<svg id="menu-icon-close" class="hidden h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</nav>
<!-- Mobile menu -->
<div
id="mobile-menu"
class="hidden border-t border-dashed border-surface-700/60 bg-surface-950/98 backdrop-blur-2xl md:hidden"
>
<div class="space-y-1 px-6 py-5">
{
navLinks.map((link) => (
<a
href={link.href}
class="block rounded-lg px-3 py-2.5 text-[13px] font-medium uppercase tracking-[0.12em] text-surface-400 transition-colors hover:bg-surface-800/50 hover:text-accent-400"
>
{link.label}
</a>
))
}
<div class="flex items-center gap-3 pt-3">
<a
href="https://github.com/4gray/iptvnator"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-2 rounded-lg px-3 py-2.5 text-[13px] font-medium text-surface-400 transition-colors hover:text-surface-200"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
</svg>
GitHub
</a>
<a
href="https://github.com/4gray/iptvnator/releases/latest"
class="flex-1 rounded-lg border border-accent-500/30 bg-accent-500/10 px-3 py-2.5 text-center text-[13px] font-medium text-accent-400 transition-all hover:border-accent-400/50 hover:bg-accent-500/20"
>
Download
</a>
</div>
</div>
</div>
</header>
<script>
// Mobile menu toggle
const btn = document.getElementById('mobile-menu-btn');
const menu = document.getElementById('mobile-menu');
const iconOpen = document.getElementById('menu-icon-open');
const iconClose = document.getElementById('menu-icon-close');
btn?.addEventListener('click', () => {
const isHidden = menu?.classList.toggle('hidden');
iconOpen?.classList.toggle('hidden', !isHidden);
iconClose?.classList.toggle('hidden', isHidden);
});
// Header scroll effect
const header = document.getElementById('site-header');
const headerBg = document.getElementById('header-bg');
let lastScroll = 0;
window.addEventListener('scroll', () => {
const scroll = window.scrollY;
if (scroll > 50) {
headerBg?.classList.remove('border-surface-700/0', 'bg-surface-950/0', 'backdrop-blur-none');
headerBg?.classList.add('border-surface-700/40', 'bg-surface-950/85', 'backdrop-blur-xl');
} else {
headerBg?.classList.add('border-surface-700/0', 'bg-surface-950/0', 'backdrop-blur-none');
headerBg?.classList.remove('border-surface-700/40', 'bg-surface-950/85', 'backdrop-blur-xl');
}
lastScroll = scroll;
});
</script>
+189
View File
@@ -0,0 +1,189 @@
---
import { Image } from 'astro:assets';
import heroImg from '../assets/screenshots/hero-dark.png';
import mascotImg from '../assets/illustrations/iptvnator-1-mascot.png';
const heroSocialLinks = [
{
label: 'GitHub',
href: 'https://github.com/4gray/iptvnator',
icon: 'M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z',
},
{
label: 'Telegram',
href: 'https://t.me/iptvnator',
icon: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69.01-.03.01-.14-.07-.2-.08-.06-.19-.04-.27-.02-.12.03-1.99 1.27-5.62 3.72-.53.36-1.01.54-1.44.53-.47-.01-1.38-.27-2.06-.49-.83-.27-1.49-.42-1.43-.88.03-.24.37-.49 1.02-.74 3.99-1.74 6.65-2.89 7.99-3.44 3.81-1.58 4.6-1.86 5.12-1.87.11 0 .37.03.54.17.14.12.18.28.2.45-.01.06.01.24 0 .38z',
},
{
label: 'GitHub Sponsors',
href: 'https://github.com/sponsors/4gray',
icon: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
},
{
label: 'Ko-fi',
href: 'https://ko-fi.com/4gray',
icon: 'M4 5h12a4 4 0 0 1 0 8h-1v2.5A4.5 4.5 0 0 1 10.5 20h-1A4.5 4.5 0 0 1 5 15.5V13H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Zm11 6h1a2 2 0 0 0 0-4h-1v4Z',
},
];
---
<section class="relative overflow-hidden pt-36 pb-24 sm:pt-44 sm:pb-32">
<!-- Ambient glow -->
<div class="pointer-events-none absolute top-0 left-1/2 h-[600px] w-[900px] -translate-x-1/2 -translate-y-1/3 rounded-full bg-accent-500/[0.06] blur-[120px]"></div>
<div class="pointer-events-none absolute inset-x-0 top-8 z-0 overflow-hidden opacity-35 mix-blend-screen">
<img
src="/iptvnator/illustrations/hero-ambient-broadcast-waves.svg"
alt=""
aria-hidden="true"
class="mx-auto h-[18rem] w-[140%] max-w-none object-cover md:h-[22rem] md:w-full lg:h-[26rem]"
loading="eager"
/>
</div>
<div class="relative mx-auto max-w-6xl px-6">
<div class="pointer-events-none absolute -top-4 -right-12 z-0 hidden w-[18rem] opacity-75 blur-[0.2px] md:block lg:w-[22rem] xl:w-[25rem]">
<div class="absolute inset-0 rounded-full bg-accent-500/[0.16] blur-3xl"></div>
<Image
src={mascotImg}
alt=""
aria-hidden="true"
width={520}
class="relative w-full object-contain saturate-110 animate-glow-pulse"
/>
</div>
<!-- Top section: editorial intro -->
<div class="mx-auto max-w-3xl text-center">
<!-- Mono label -->
<div class="mb-8 inline-flex items-center gap-3">
<span class="h-px w-8 bg-accent-500/50"></span>
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Open source IPTV player</span>
<span class="h-px w-8 bg-accent-500/50"></span>
</div>
<!-- Headline with serif italic -->
<h1 class="font-display text-5xl font-bold tracking-tight text-surface-50 sm:text-7xl lg:text-8xl">
Watch, <em class="font-medium not-italic text-accent-400" style="font-style: italic;">your way.</em>
</h1>
<p class="mx-auto mt-8 max-w-xl text-lg leading-relaxed text-surface-400">
A cross-platform player for M3U/M3U8 playlists, Xtream Codes API, and
Stalker portals. Desktop app and Progressive Web App.
</p>
<!-- CTA: Big TV-effect download button -->
<div class="mt-12 flex flex-col items-center gap-6">
<a
href="https://github.com/4gray/iptvnator/releases/latest"
class="btn-tv group rounded-2xl px-10 py-6 sm:px-14 sm:py-7"
>
<!-- TV effect layers -->
<span class="tv-scanline"></span>
<span class="tv-noise"></span>
<span class="tv-vignette"></span>
<!-- Button content -->
<span class="relative z-10 flex items-center gap-4">
<svg class="h-6 w-6 text-accent-400 transition-transform duration-300 group-hover:translate-y-0.5 sm:h-7 sm:w-7" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
<span class="flex flex-col items-start">
<span class="btn-tv-text font-display text-2xl font-bold tracking-tight text-surface-50 sm:text-3xl">
Download <em class="font-medium text-accent-400" style="font-style: italic;">for Desktop</em>
</span>
<span class="font-mono text-[10px] uppercase tracking-[0.2em] text-surface-500">
macOS · Windows · Linux
</span>
</span>
</span>
</a>
<!-- Secondary: Web app link -->
<a
href="https://iptvnator.vercel.app"
class="group inline-flex items-center gap-2.5 rounded-lg border border-dashed border-surface-600/60 px-7 py-3.5 text-sm font-medium text-surface-400 transition-all duration-300 hover:border-accent-500/30 hover:text-accent-400"
>
<svg class="h-4 w-4 transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
Or try the Web App
<svg class="h-3.5 w-3.5 transition-transform duration-200 group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
</div>
<!-- Shields badges -->
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
<img
src="https://img.shields.io/github/stars/4gray/iptvnator?style=flat&color=20a8a8&labelColor=1a1a17"
alt="GitHub stars"
class="h-5"
loading="lazy"
/>
<img
src="https://img.shields.io/github/v/release/4gray/iptvnator?style=flat&color=20a8a8&labelColor=1a1a17"
alt="Latest release"
class="h-5"
loading="lazy"
/>
<img
src="https://img.shields.io/github/downloads/4gray/iptvnator/total?style=flat&color=20a8a8&labelColor=1a1a17"
alt="Total downloads"
class="h-5"
loading="lazy"
/>
</div>
<div class="mt-4 flex flex-wrap items-center justify-center gap-2.5">
{heroSocialLinks.map((link) => (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
class="group inline-flex items-center gap-2 rounded-full border border-dashed border-surface-700/70 bg-surface-900/40 px-3 py-1.5 text-[11px] font-mono uppercase tracking-[0.08em] text-surface-400 transition-all duration-300 hover:border-accent-500/40 hover:bg-accent-500/[0.06] hover:text-accent-300"
aria-label={link.label}
title={link.label}
>
<svg class="h-3.5 w-3.5 text-surface-500 transition-colors duration-300 group-hover:text-accent-300" fill="currentColor" viewBox="0 0 24 24">
<path d={link.icon} />
</svg>
<span>{link.label}</span>
</a>
))}
</div>
</div>
<!-- Hero screenshot with window chrome frame -->
<div class="relative mx-auto mt-20 max-w-5xl">
<!-- Ambient glow behind image -->
<div class="absolute -inset-8 rounded-2xl bg-accent-500/[0.04] blur-3xl"></div>
<!-- Window chrome -->
<div class="relative overflow-hidden rounded-xl border border-surface-700/60 bg-surface-900 shadow-2xl shadow-black/40">
<!-- Title bar -->
<div class="flex items-center gap-2 border-b border-dashed border-surface-700/60 px-4 py-2.5">
<div class="flex gap-1.5">
<span class="h-2.5 w-2.5 rounded-full bg-red-500/70"></span>
<span class="h-2.5 w-2.5 rounded-full bg-yellow-500/70"></span>
<span class="h-2.5 w-2.5 rounded-full bg-green-500/70"></span>
</div>
<span class="ml-2 font-mono text-[11px] text-surface-500">IPTVnator</span>
</div>
<!-- Scanline overlay -->
<div class="relative">
<div class="pointer-events-none absolute inset-0 z-10 overflow-hidden opacity-[0.03]">
<div class="h-full w-full animate-scan" style="background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(255,255,255,0.5) 2px, rgba(255,255,255,0.5) 4px);"></div>
</div>
<Image
src={heroImg}
alt="IPTVnator application interface showing dark theme with channel list and video player"
width={1200}
class="w-full"
/>
</div>
</div>
</div>
</div>
</section>
@@ -0,0 +1,128 @@
---
import { Image } from 'astro:assets';
import playerImg from '../assets/screenshots/player.png';
import epgImg from '../assets/screenshots/epg.png';
import playlistsImg from '../assets/screenshots/playlists.png';
import settingsImg from '../assets/screenshots/settings.png';
import uploadFileImg from '../assets/screenshots/upload-file.png';
const tabs = [
{ id: 'player', label: 'Player', image: playerImg, alt: 'IPTVnator video player interface' },
{ id: 'epg', label: 'EPG Guide', image: epgImg, alt: 'Electronic program guide view' },
{ id: 'playlists', label: 'Playlists', image: playlistsImg, alt: 'Playlist management screen' },
{ id: 'settings', label: 'Settings', image: settingsImg, alt: 'Application settings' },
{ id: 'upload', label: 'Add Playlist', image: uploadFileImg, alt: 'Add playlist dialog' },
];
---
<section id="screenshots" class="relative py-28">
<hr class="section-divider mb-28" />
<div class="mx-auto max-w-6xl px-6">
<!-- Editorial header: right-aligned for variety -->
<div class="ml-auto max-w-xl text-right">
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">
Interface
</span>
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
See it <em class="font-medium text-accent-400" style="font-style: italic;">in action</em>
</h2>
<p class="mt-4 text-lg text-surface-400">
A clean, intuitive interface designed for the best viewing experience.
</p>
</div>
<div class="mt-14">
<!-- Tab buttons with underline style -->
<div class="flex flex-wrap justify-center gap-1 rounded-lg border border-dashed border-surface-700/60 bg-surface-900/50 p-1.5" role="tablist">
{
tabs.map((tab, i) => (
<button
role="tab"
aria-selected={i === 0 ? 'true' : 'false'}
data-tab={tab.id}
class:list={[
'screenshot-tab rounded-md px-5 py-2 font-mono text-xs uppercase tracking-[0.1em] transition-all duration-200',
i === 0
? 'bg-accent-500/15 text-accent-400'
: 'text-surface-500 hover:text-surface-300',
]}
>
{tab.label}
</button>
))
}
</div>
<!-- Screenshots with window chrome -->
<div class="relative mt-8">
{
tabs.map((tab, i) => (
<div
data-panel={tab.id}
class:list={[
'screenshot-panel transition-opacity duration-300',
i !== 0 && 'hidden',
]}
>
<div class="relative mx-auto max-w-4xl">
<!-- Ambient glow -->
<div class="absolute -inset-6 rounded-2xl bg-accent-500/[0.03] blur-2xl" />
<!-- Window chrome -->
<div class="relative overflow-hidden rounded-xl border border-surface-700/60 bg-surface-900 shadow-2xl shadow-black/30">
<div class="flex items-center gap-2 border-b border-dashed border-surface-700/60 px-4 py-2.5">
<div class="flex gap-1.5">
<span class="h-2.5 w-2.5 rounded-full bg-red-500/70" />
<span class="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
<span class="h-2.5 w-2.5 rounded-full bg-green-500/70" />
</div>
<span class="ml-2 font-mono text-[11px] text-surface-500">{tab.label}</span>
</div>
<Image
src={tab.image}
alt={tab.alt}
width={1000}
class="w-full"
/>
</div>
</div>
</div>
))
}
</div>
</div>
</div>
</section>
<script>
const tabs = document.querySelectorAll('.screenshot-tab');
const panels = document.querySelectorAll('.screenshot-panel');
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
const target = (tab as HTMLElement).dataset.tab;
// Reset all tabs
tabs.forEach((t) => {
t.classList.remove('bg-accent-500/15', 'text-accent-400');
t.classList.add('text-surface-500');
t.setAttribute('aria-selected', 'false');
});
// Activate clicked tab
tab.classList.add('bg-accent-500/15', 'text-accent-400');
tab.classList.remove('text-surface-500');
tab.setAttribute('aria-selected', 'true');
// Show/hide panels
panels.forEach((panel) => {
if ((panel as HTMLElement).dataset.panel === target) {
panel.classList.remove('hidden');
} else {
panel.classList.add('hidden');
}
});
});
});
</script>
@@ -0,0 +1,86 @@
---
const supportLinks = [
{
label: 'Give a Star',
href: 'https://github.com/4gray/iptvnator/stargazers',
description: 'The fastest way to help visibility',
icon: 'M12 2.5l2.94 5.96 6.58.96-4.76 4.64 1.12 6.56L12 17.53 6.12 20.62l1.12-6.56-4.76-4.64 6.58-.96L12 2.5z',
},
{
label: 'GitHub Sponsors',
href: 'https://github.com/sponsors/4gray',
description: 'Monthly support keeps development steady',
icon: 'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
},
{
label: 'Ko-fi',
href: 'https://ko-fi.com/4gray',
description: 'One-time donations are always appreciated',
icon: 'M3 6.5A2.5 2.5 0 0 1 5.5 4h10A2.5 2.5 0 0 1 18 6.5V8h1.5A2.5 2.5 0 0 1 22 10.5v1A2.5 2.5 0 0 1 19.5 14H18v1.5A4.5 4.5 0 0 1 13.5 20h-6A4.5 4.5 0 0 1 3 15.5v-9zm15 5.5h1.5a.5.5 0 0 0 .5-.5v-1a.5.5 0 0 0-.5-.5H18V12z',
},
];
---
<section class="relative py-28">
<hr class="section-divider mb-24" />
<div class="pointer-events-none absolute inset-x-0 top-12 mx-auto h-56 w-[42rem] rounded-full bg-accent-500/[0.05] blur-3xl"></div>
<div class="relative mx-auto max-w-6xl px-6">
<div class="grid gap-6 lg:grid-cols-[1.15fr_0.85fr]">
<div class="card-dotted relative overflow-hidden rounded-2xl bg-surface-900/35">
<div class="pointer-events-none absolute -top-12 -right-14 hidden h-52 w-52 opacity-35 mix-blend-screen md:block">
<img
src="/iptvnator/illustrations/support-open-source-signal.svg"
alt=""
aria-hidden="true"
class="h-full w-full object-contain"
loading="lazy"
/>
</div>
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Support</span>
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl">
IPTVnator stays <em class="font-medium text-accent-400" style="font-style: italic;">open source</em>
</h2>
<p class="mt-5 max-w-2xl text-base leading-relaxed text-surface-300 md:pr-24">
IPTVnator was built as an open-source project and will remain one. If it helps you, your support keeps the
project healthy and the motivation high for continued improvements, fixes, and long-term maintenance.
</p>
<div class="mt-8 grid gap-3 sm:grid-cols-3">
{supportLinks.map((item) => (
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
class="group flex h-full flex-col rounded-xl border border-dashed border-surface-700/70 bg-surface-950/50 p-4 transition-all duration-300 hover:border-accent-500/40 hover:bg-accent-500/[0.06]"
>
<div class="flex min-h-[3rem] items-center gap-3 text-surface-200">
<span class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-accent-500/25 bg-accent-500/10">
<svg class="h-5 w-5 text-accent-300 transition-transform duration-300 group-hover:scale-110" fill="currentColor" viewBox="0 0 24 24">
<path d={item.icon} />
</svg>
</span>
<span class="text-sm font-semibold text-surface-100">{item.label}</span>
</div>
<p class="mt-3 text-sm leading-relaxed text-surface-400">{item.description}</p>
</a>
))}
</div>
</div>
<aside class="card-dotted rounded-2xl bg-surface-900/25">
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">Disclaimer</span>
<p class="mt-4 text-sm leading-relaxed text-surface-300">
IPTVnator is a media player application. It does not provide, host, or sell any digital media content,
playlists, channels, or EPG data.
</p>
<p class="mt-3 text-sm leading-relaxed text-surface-400">
Any channel names, logos, metadata, or playlists shown in screenshots are for demonstration purposes only.
You are responsible for the sources you use and for complying with local laws and rights.
</p>
</aside>
</div>
</div>
</section>
@@ -0,0 +1,44 @@
---
interface Props {
type?: 'info' | 'success' | 'warning';
title?: string;
}
const { type = 'info', title } = Astro.props;
const styles = {
info: {
wrapper: 'border-accent-500/35 bg-accent-500/[0.07]',
icon: 'text-accent-300',
title: 'text-accent-200',
},
success: {
wrapper: 'border-emerald-500/35 bg-emerald-500/[0.08]',
icon: 'text-emerald-300',
title: 'text-emerald-200',
},
warning: {
wrapper: 'border-amber-500/35 bg-amber-500/[0.09]',
icon: 'text-amber-300',
title: 'text-amber-200',
},
} as const;
const variant = styles[type];
---
<div class={`not-prose my-6 rounded-xl border border-dashed p-4 sm:p-5 ${variant.wrapper}`}>
<div class="flex items-start gap-3">
<span class={`mt-0.5 ${variant.icon}`} aria-hidden="true">
<svg class="h-4.5 w-4.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 8.25v4.5m0 3h.008M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</span>
<div class="min-w-0">
{title && <p class={`m-0 text-sm font-semibold ${variant.title}`}>{title}</p>}
<div class={`mt-1 text-sm leading-relaxed text-surface-200 [&_p]:my-0 [&_code]:rounded [&_code]:bg-surface-900/50 [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs`}>
<slot />
</div>
</div>
</div>
</div>
@@ -0,0 +1,134 @@
---
interface SliderImage {
src: string;
alt: string;
caption?: string;
}
interface Props {
images: SliderImage[];
thumbPosition?: 'bottom' | 'right';
showArrows?: boolean;
}
const { images = [], thumbPosition = 'bottom', showArrows = true } = Astro.props;
const sliderId = `slider-${Math.random().toString(36).slice(2, 10)}`;
---
{images.length > 0 && (
<div
class="not-prose my-6"
data-blog-slider={sliderId}
tabindex="0"
>
<div class="group relative overflow-hidden rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50">
<img
src={images[0].src}
alt={images[0].alt}
data-slider-main-img
class="aspect-video w-full object-cover"
loading="lazy"
/>
{showArrows && images.length > 1 && (
<>
<button
type="button"
class="slider-prev absolute left-2 top-1/2 -translate-y-1/2 rounded-md border border-surface-700/70 bg-surface-950/70 px-2 py-1.5 text-surface-200 transition-colors hover:text-accent-300"
aria-label="Previous image"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<button
type="button"
class="slider-next absolute right-2 top-1/2 -translate-y-1/2 rounded-md border border-surface-700/70 bg-surface-950/70 px-2 py-1.5 text-surface-200 transition-colors hover:text-accent-300"
aria-label="Next image"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</>
)}
</div>
<p data-slider-caption class="mt-2 text-sm text-surface-500">{images[0].caption ?? ''}</p>
{images.length > 1 && (
<div class="mt-3 flex gap-2 overflow-x-auto pb-1" role="tablist" aria-label="Image thumbnails">
{images.map((image, index) => (
<button
type="button"
data-slider-thumb
data-index={index}
data-src={image.src}
data-alt={image.alt}
data-caption={image.caption ?? ''}
class={`shrink-0 overflow-hidden rounded-md border ${index === 0 ? 'border-accent-500/60' : 'border-surface-700/70'} bg-surface-900/50 transition-all duration-200 hover:border-accent-500/50`}
aria-label={`Show image ${index + 1}`}
aria-selected={index === 0 ? 'true' : 'false'}
>
<img src={image.src} alt={image.alt} loading="lazy" class="h-12 w-20 object-cover sm:h-14 sm:w-24" />
</button>
))}
</div>
)}
</div>
)}
<script>
const initBlogSliders = () => {
document.querySelectorAll('[data-blog-slider]').forEach((slider) => {
if (slider.getAttribute('data-bound') === 'true') return;
slider.setAttribute('data-bound', 'true');
const main = slider.querySelector('[data-slider-main-img]');
const caption = slider.querySelector('[data-slider-caption]');
const thumbs = Array.from(slider.querySelectorAll('[data-slider-thumb]'));
const prev = slider.querySelector('.slider-prev');
const next = slider.querySelector('.slider-next');
if (!main || thumbs.length === 0) return;
let current = 0;
const setActive = (idx) => {
current = (idx + thumbs.length) % thumbs.length;
const activeBtn = thumbs[current];
const src = activeBtn.getAttribute('data-src') || '';
const alt = activeBtn.getAttribute('data-alt') || '';
const cap = activeBtn.getAttribute('data-caption') || '';
main.setAttribute('src', src);
main.setAttribute('alt', alt);
if (caption) caption.textContent = cap;
thumbs.forEach((btn, i) => {
const selected = i === current;
btn.setAttribute('aria-selected', selected ? 'true' : 'false');
btn.classList.toggle('border-accent-500/60', selected);
btn.classList.toggle('border-surface-700/70', !selected);
});
};
thumbs.forEach((btn, i) => {
btn.addEventListener('click', () => setActive(i));
});
prev?.addEventListener('click', () => setActive(current - 1));
next?.addEventListener('click', () => setActive(current + 1));
slider.addEventListener('keydown', (e) => {
if (e.key === 'ArrowLeft') setActive(current - 1);
if (e.key === 'ArrowRight') setActive(current + 1);
});
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBlogSliders);
} else {
initBlogSliders();
}
</script>
@@ -0,0 +1,34 @@
---
interface ChangeRow {
area: string;
change: string;
impact?: string;
}
interface Props {
rows: ChangeRow[];
}
const { rows = [] } = Astro.props;
---
<div class="not-prose my-6 overflow-x-auto rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50">
<table class="w-full min-w-[560px] border-collapse text-left">
<thead>
<tr class="border-b border-dashed border-surface-700/60">
<th class="px-4 py-3 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">Area</th>
<th class="px-4 py-3 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">Change</th>
<th class="px-4 py-3 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">Impact</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr class="border-b border-dashed border-surface-700/40 last:border-b-0">
<td class="px-4 py-3 text-sm font-semibold text-surface-200">{row.area}</td>
<td class="px-4 py-3 text-sm text-surface-300">{row.change}</td>
<td class="px-4 py-3 text-sm text-surface-400">{row.impact ?? '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
@@ -0,0 +1,43 @@
---
interface Props {
command: string;
title?: string;
}
const { command, title = 'Command' } = Astro.props;
---
<div class="not-prose my-6 rounded-xl border border-dashed border-surface-700/70 bg-surface-900/55 p-3">
<div class="mb-2 flex items-center justify-between">
<span class="font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">{title}</span>
<button class="copy-command-btn rounded-md border border-dashed border-surface-700/70 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.08em] text-surface-400 transition-colors hover:text-accent-300" data-command={command}>
Copy
</button>
</div>
<code class="block overflow-x-auto rounded-md bg-surface-950/70 px-3 py-2 font-mono text-sm text-accent-200">{command}</code>
</div>
<script>
const initCopyButtons = () => {
document.querySelectorAll('.copy-command-btn').forEach((btn) => {
if (btn.getAttribute('data-bound') === 'true') return;
btn.setAttribute('data-bound', 'true');
btn.addEventListener('click', async () => {
const cmd = btn.getAttribute('data-command');
if (!cmd) return;
await navigator.clipboard.writeText(cmd);
const prev = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => {
btn.textContent = prev || 'Copy';
}, 1400);
});
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initCopyButtons);
} else {
initCopyButtons();
}
</script>
@@ -0,0 +1,26 @@
---
interface FaqItem {
q: string;
a: string;
}
interface Props {
items: FaqItem[];
}
const { items = [] } = Astro.props;
---
<div class="not-prose my-6 space-y-2">
{items.map((item) => (
<details class="group rounded-xl border border-dashed border-surface-700/70 bg-surface-900/45 px-4 py-3">
<summary class="cursor-pointer list-none">
<span class="flex items-center justify-between gap-3 text-sm font-semibold text-surface-100">
<span>{item.q}</span>
<span class="text-surface-500 transition-transform duration-200 group-open:rotate-45">+</span>
</span>
</summary>
<p class="mt-2 border-t border-dashed border-surface-700/60 pt-2 text-sm leading-relaxed text-surface-400">{item.a}</p>
</details>
))}
</div>
@@ -0,0 +1,37 @@
---
interface FeatureItem {
title: string;
text: string;
icon?: 'spark' | 'shield' | 'speed' | 'playlist' | 'epg';
}
interface Props {
items: FeatureItem[];
}
const { items = [] } = Astro.props;
const iconPaths = {
spark: 'M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z',
shield: 'M12 2l7 4v6c0 5-3.4 9.7-7 10-3.6-.3-7-5-7-10V6l7-4z',
speed: 'M12 3v2m6.364.636l-1.414 1.414M21 12h-2m-.636 6.364l-1.414-1.414M12 21v-2m-6.364-.636l1.414-1.414M3 12h2m.636-6.364l1.414 1.414M15 12l-4 4',
playlist: 'M4 6h14M4 10h14M4 14h10M4 18h10M18 16l3 2-3 2v-4z',
epg: 'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
} as const;
---
<div class="not-prose my-6 grid gap-3 sm:grid-cols-2">
{items.map((item) => (
<div class="rounded-xl border border-dashed border-surface-700/70 bg-surface-900/45 p-4">
<div class="mb-2.5 flex items-center gap-2.5">
<span class="flex h-7 w-7 items-center justify-center rounded-md border border-accent-500/25 bg-accent-500/[0.1]">
<svg class="h-3.5 w-3.5 text-accent-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7">
<path stroke-linecap="round" stroke-linejoin="round" d={iconPaths[item.icon ?? 'spark']} />
</svg>
</span>
<h4 class="m-0 text-sm font-semibold text-surface-100">{item.title}</h4>
</div>
<p class="m-0 text-sm leading-relaxed text-surface-400">{item.text}</p>
</div>
))}
</div>
@@ -0,0 +1,99 @@
---
interface GalleryImage {
src: string;
alt: string;
caption?: string;
}
interface Props {
images: GalleryImage[];
columns?: 2 | 3 | 4;
}
const { images = [], columns = 3 } = Astro.props;
const colClass = {
2: 'sm:grid-cols-2',
3: 'sm:grid-cols-2 lg:grid-cols-3',
4: 'sm:grid-cols-2 lg:grid-cols-4',
} as const;
---
<div class="not-prose my-6">
<div class={`grid gap-3 ${colClass[columns]}`}>
{images.map((img, idx) => (
<figure class="group overflow-hidden rounded-xl border border-dashed border-surface-700/70 bg-surface-900/45">
<button
type="button"
class="gallery-open block w-full"
data-src={img.src}
data-alt={img.alt}
data-caption={img.caption ?? ''}
aria-label={`Open image ${idx + 1}`}
>
<img
src={img.src}
alt={img.alt}
loading="lazy"
class="aspect-video w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
/>
</button>
{img.caption && <figcaption class="px-3 py-2 text-xs text-surface-500">{img.caption}</figcaption>}
</figure>
))}
</div>
</div>
<div id="blog-gallery-lightbox" class="fixed inset-0 z-[80] hidden items-center justify-center bg-black/85 p-4" aria-hidden="true">
<button type="button" class="gallery-close absolute right-4 top-4 rounded-md border border-surface-600/60 bg-surface-900/70 px-2.5 py-1 text-xs text-surface-300">Close</button>
<figure class="max-h-[92vh] max-w-[92vw]">
<img id="blog-gallery-lightbox-img" src="" alt="" class="max-h-[82vh] max-w-[92vw] rounded-lg border border-surface-700/70 object-contain" />
<figcaption id="blog-gallery-lightbox-cap" class="mt-2 text-center text-sm text-surface-300"></figcaption>
</figure>
</div>
<script>
const initBlogGallery = () => {
const lightbox = document.getElementById('blog-gallery-lightbox');
const lightboxImg = document.getElementById('blog-gallery-lightbox-img');
const lightboxCap = document.getElementById('blog-gallery-lightbox-cap');
if (!lightbox || !lightboxImg || !lightboxCap) return;
document.querySelectorAll('.gallery-open').forEach((btn) => {
if (btn.getAttribute('data-bound') === 'true') return;
btn.setAttribute('data-bound', 'true');
btn.addEventListener('click', () => {
const src = btn.getAttribute('data-src') || '';
const alt = btn.getAttribute('data-alt') || '';
const caption = btn.getAttribute('data-caption') || '';
lightboxImg.setAttribute('src', src);
lightboxImg.setAttribute('alt', alt);
lightboxCap.textContent = caption;
lightbox.classList.remove('hidden');
lightbox.classList.add('flex');
lightbox.setAttribute('aria-hidden', 'false');
});
});
const close = () => {
lightbox.classList.add('hidden');
lightbox.classList.remove('flex');
lightbox.setAttribute('aria-hidden', 'true');
lightboxImg.setAttribute('src', '');
};
lightbox.querySelector('.gallery-close')?.addEventListener('click', close);
lightbox.addEventListener('click', (e) => {
if (e.target === lightbox) close();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && lightbox.getAttribute('aria-hidden') === 'false') close();
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initBlogGallery);
} else {
initBlogGallery();
}
</script>
@@ -0,0 +1,51 @@
---
interface LinkItem {
label: string;
href: string;
hint?: string;
icon?: 'github' | 'telegram' | 'sponsor' | 'kofi' | 'download' | 'docs';
}
interface Props {
links: LinkItem[];
}
const { links = [] } = Astro.props;
const iconPaths = {
github:
'M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z',
telegram:
'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8c-.15 1.58-.8 5.42-1.13 7.19-.14.75-.42 1-.68 1.03-.58.05-1.02-.38-1.58-.75-.88-.58-1.38-.94-2.23-1.5-.99-.65-.35-1.01.22-1.59.15-.15 2.71-2.48 2.76-2.69.01-.03.01-.14-.07-.2-.08-.06-.19-.04-.27-.02-.12.03-1.99 1.27-5.62 3.72-.53.36-1.01.54-1.44.53-.47-.01-1.38-.27-2.06-.49-.83-.27-1.49-.42-1.43-.88.03-.24.37-.49 1.02-.74 3.99-1.74 6.65-2.89 7.99-3.44 3.81-1.58 4.6-1.86 5.12-1.87.11 0 .37.03.54.17.14.12.18.28.2.45-.01.06.01.24 0 .38z',
sponsor:
'M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z',
kofi: 'M4 5h12a4 4 0 0 1 0 8h-1v2.5A4.5 4.5 0 0 1 10.5 20h-1A4.5 4.5 0 0 1 5 15.5V13H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2Zm11 6h1a2 2 0 0 0 0-4h-1v4Z',
download: 'M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4',
docs: 'M12 6.253v13m0-13C10.832 5.483 9.246 5 7.5 5 5.205 5 3.182 5.836 2 7.117v11.41C3.182 17.247 5.205 16.41 7.5 16.41c1.746 0 3.332.484 4.5 1.254m0-11.41C13.168 5.483 14.754 5 16.5 5c2.295 0 4.318.836 5.5 2.117v11.41C20.818 17.247 18.795 16.41 16.5 16.41c-1.746 0-3.332.484-4.5 1.254',
} as const;
---
<div class="not-prose my-6 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{links.map((item) => (
<a
href={item.href}
target="_blank"
rel="noopener noreferrer"
class="group rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50 p-4 transition-all duration-300 hover:border-accent-500/40 hover:bg-accent-500/[0.06]"
>
<div class="flex items-center gap-2.5">
<span class="flex h-7 w-7 items-center justify-center rounded-md border border-accent-500/25 bg-accent-500/[0.09]">
<svg class="h-3.5 w-3.5 text-accent-300" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.7">
{item.icon === 'download' || item.icon === 'docs' ? (
<path stroke-linecap="round" stroke-linejoin="round" d={iconPaths[item.icon]} />
) : (
<path fill="currentColor" stroke="none" d={iconPaths[item.icon ?? 'docs']} />
)}
</svg>
</span>
<span class="text-sm font-semibold text-surface-100">{item.label}</span>
</div>
{item.hint && <p class="mt-2 text-xs leading-relaxed text-surface-500">{item.hint}</p>}
</a>
))}
</div>
@@ -0,0 +1,21 @@
---
interface Props {
href: string;
label?: string;
external?: boolean;
}
const { href, label, external = true } = Astro.props;
---
<a
href={href}
target={external ? '_blank' : undefined}
rel={external ? 'noopener noreferrer' : undefined}
class="not-prose group my-6 inline-flex items-center gap-2 rounded-lg border border-dashed border-accent-500/35 bg-accent-500/[0.08] px-4 py-2.5 text-sm font-medium text-accent-200 transition-all duration-200 hover:border-accent-400/50 hover:bg-accent-500/[0.14] hover:text-accent-100"
>
<span>{label ?? 'Open link'}</span>
<svg class="h-4 w-4 transition-transform duration-200 group-hover:translate-x-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5H19.5m0 0v6m0-6L10.5 13.5M6 6h3.75M6 10.5V18a1.5 1.5 0 001.5 1.5H15" />
</svg>
</a>
@@ -0,0 +1,26 @@
---
interface Props {
version: string;
releaseDate: string;
channels?: string[];
}
const { version, releaseDate, channels = [] } = Astro.props;
---
<div class="not-prose my-6 rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50 p-4 sm:p-5">
<div class="grid gap-3 sm:grid-cols-3">
<div>
<p class="m-0 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">Version</p>
<p class="mt-1 text-sm font-semibold text-surface-100">{version}</p>
</div>
<div>
<p class="m-0 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">Release date</p>
<p class="mt-1 text-sm font-semibold text-surface-100">{releaseDate}</p>
</div>
<div>
<p class="m-0 font-mono text-[10px] uppercase tracking-[0.12em] text-surface-500">Channels</p>
<p class="mt-1 text-sm font-semibold text-surface-100">{channels.length ? channels.join(' · ') : 'N/A'}</p>
</div>
</div>
</div>
@@ -0,0 +1,22 @@
---
interface Props {
title?: string;
open?: boolean;
}
const { title = 'Show spoiler', open = false } = Astro.props;
---
<details class="not-prose my-5 rounded-xl border border-dashed border-surface-700/70 bg-surface-900/50 px-4 py-3" open={open}>
<summary class="cursor-pointer list-none font-mono text-xs uppercase tracking-[0.1em] text-accent-300">
<span class="inline-flex items-center gap-2">
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.8">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.968 9.968 0 012.06-3.368m2.25-1.93A9.956 9.956 0 0112 5c4.478 0 8.268 2.943 9.543 7a9.964 9.964 0 01-4.293 5.274M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{title}
</span>
</summary>
<div class="mt-3 border-t border-dashed border-surface-700/60 pt-3 text-sm leading-relaxed text-surface-300 [&_p]:my-2">
<slot />
</div>
</details>
@@ -0,0 +1,20 @@
---
interface Props {
type?: 'new' | 'fixed' | 'improved' | 'breaking' | 'beta';
label?: string;
}
const { type = 'new', label } = Astro.props;
const styles = {
new: 'border-cyan-500/35 bg-cyan-500/[0.12] text-cyan-200',
fixed: 'border-emerald-500/35 bg-emerald-500/[0.12] text-emerald-200',
improved: 'border-accent-500/35 bg-accent-500/[0.12] text-accent-200',
breaking: 'border-rose-500/35 bg-rose-500/[0.12] text-rose-200',
beta: 'border-amber-500/35 bg-amber-500/[0.12] text-amber-200',
} as const;
---
<span class={`not-prose inline-flex items-center rounded-full border border-dashed px-2.5 py-0.5 font-mono text-[10px] uppercase tracking-[0.08em] ${styles[type]}`}>
{label ?? type}
</span>
@@ -0,0 +1,22 @@
---
interface Props {
steps: string[];
title?: string;
}
const { steps = [], title } = Astro.props;
---
<div class="not-prose my-6 rounded-xl border border-dashed border-surface-700/70 bg-surface-900/45 p-4 sm:p-5">
{title && <p class="mb-3 font-mono text-[11px] uppercase tracking-[0.12em] text-accent-400">{title}</p>}
<ol class="m-0 list-none space-y-3 p-0">
{steps.map((step, index) => (
<li class="flex items-start gap-3">
<span class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-dashed border-accent-500/35 bg-accent-500/[0.1] font-mono text-[11px] font-semibold text-accent-200">
{index + 1}
</span>
<p class="m-0 pt-0.5 text-sm leading-relaxed text-surface-200">{step}</p>
</li>
))}
</ol>
</div>
@@ -0,0 +1,19 @@
---
interface Props {
title?: string;
}
const { title = 'Terminal' } = Astro.props;
---
<div class="not-prose my-6 overflow-hidden rounded-xl border border-dashed border-surface-700/70 bg-surface-900/60">
<div class="flex items-center justify-between border-b border-dashed border-surface-700/60 px-4 py-2.5">
<div class="flex items-center gap-1.5" aria-hidden="true">
<span class="h-2.5 w-2.5 rounded-full bg-red-500/70"></span>
<span class="h-2.5 w-2.5 rounded-full bg-yellow-500/70"></span>
<span class="h-2.5 w-2.5 rounded-full bg-green-500/70"></span>
</div>
<span class="font-mono text-[11px] uppercase tracking-[0.12em] text-surface-500">{title}</span>
</div>
<pre class="m-0 overflow-x-auto p-4"><code class="font-mono text-sm text-accent-200"><slot /></code></pre>
</div>
+19
View File
@@ -0,0 +1,19 @@
import { glob } from 'astro/loaders';
import { defineCollection, z } from 'astro:content';
const blog = defineCollection({
loader: glob({ pattern: '**/[^_]*.{md,mdx}', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
pubDate: z.coerce.date(),
updatedDate: z.coerce.date().optional(),
author: z.string().default('4gray'),
featured: z.boolean().default(false),
heroImage: z.string().optional(),
tags: z.array(z.string()).default([]),
draft: z.boolean().default(false),
}),
});
export const collections = { blog };
@@ -0,0 +1,230 @@
---
title: Blog Components Demo (MDX)
description: Reusable MDX components for IPTVnator blog posts including alerts, CTA buttons, and terminal command blocks.
pubDate: 2026-02-15
author: 4gray
tags:
- blog
- mdx
- docs
draft: true
---
import Alert from '../../components/blog/Alert.astro';
import PostButton from '../../components/blog/PostButton.astro';
import TerminalBlock from '../../components/blog/TerminalBlock.astro';
import StepRail from '../../components/blog/StepRail.astro';
import LinkCards from '../../components/blog/LinkCards.astro';
import IconFeatureGrid from '../../components/blog/IconFeatureGrid.astro';
import ReleaseMeta from '../../components/blog/ReleaseMeta.astro';
import StatusPill from '../../components/blog/StatusPill.astro';
import ChangeTable from '../../components/blog/ChangeTable.astro';
import CopyCommand from '../../components/blog/CopyCommand.astro';
import ImageGallery from '../../components/blog/ImageGallery.astro';
import BlogImageSlider from '../../components/blog/BlogImageSlider.astro';
import Spoiler from '../../components/blog/Spoiler.astro';
import FaqAccordion from '../../components/blog/FaqAccordion.astro';
This post demonstrates reusable UI components you can use in `.mdx` blog posts.
## Alert panel
<Alert type="info" title="Heads up">
Use alerts to highlight important notes without breaking the reading flow.
</Alert>
<Alert type="warning" title="Before updating">
Back up playlists, credentials, and portal URLs before installing major updates.
</Alert>
## Release meta
<ReleaseMeta version="v0.18.0" releaseDate="January 14, 2026" channels={['Desktop', 'PWA']} />
## Status pills
<div class="not-prose my-4 flex flex-wrap gap-2">
<StatusPill type="new" />
<StatusPill type="fixed" />
<StatusPill type="improved" />
<StatusPill type="beta" />
<StatusPill type="breaking" />
</div>
## Terminal code block
<TerminalBlock title="Terminal">
$ xattr -c /Applications/IPTVnator.app
</TerminalBlock>
## Copy command
<CopyCommand title="One-line fix" command="xattr -c /Applications/IPTVnator.app" />
## Button / CTA
<PostButton
href="https://github.com/4gray/iptvnator/releases/latest"
label="Download latest release"
/>
<PostButton
href="/iptvnator/blog/v0-18-release-notes/"
label="Read v0.18 release notes"
external={false}
/>
## Step rail
<StepRail
title="Quick setup"
steps={[
'Import an M3U, Xtream, or Stalker playlist.',
'Configure EPG (XMLTV URL) from playlist settings.',
'Mark channels as favorites for quick access.',
]}
/>
## Link cards
<LinkCards
links={[
{
label: 'GitHub',
href: 'https://github.com/4gray/iptvnator',
hint: 'Source code, issues, and releases.',
icon: 'github',
},
{
label: 'Telegram',
href: 'https://t.me/iptvnator',
hint: 'Announcements and community updates.',
icon: 'telegram',
},
{
label: 'GitHub Sponsors',
href: 'https://github.com/sponsors/4gray',
hint: 'Support long-term development.',
icon: 'sponsor',
},
]}
/>
## Icon feature grid
<IconFeatureGrid
items={[
{
title: 'Playlist Support',
text: 'Use M3U/M3U8, Xtream Codes API, and Stalker portals.',
icon: 'playlist',
},
{
title: 'Built-in EPG',
text: 'Parse large XMLTV guides with improved loading behavior.',
icon: 'epg',
},
{
title: 'Performance',
text: 'Background workers keep UI responsive during heavy parsing.',
icon: 'speed',
},
{
title: 'Privacy-first',
text: 'No tracking pipeline and local-first playback workflow.',
icon: 'shield',
},
]}
/>
## Change table
<ChangeTable
rows={[
{
area: 'General',
change: 'Playlist switcher moved to a cleaner unified UI.',
impact: 'Faster navigation',
},
{
area: 'M3U',
change: 'EPG parsing improved for large XMLTV files.',
impact: 'More reliable loading',
},
{
area: 'Xtream',
change: 'Category visibility controls and item counters.',
impact: 'Better content management',
},
]}
/>
## Image gallery
<ImageGallery
columns={2}
images={[
{
src: '/iptvnator/blog/iptvnator-v18.jpeg',
alt: 'IPTVnator v0.18 UI screenshot',
caption: 'v0.18 interface update',
},
{
src: '/iptvnator/blog/macos-app-damaged.jpg',
alt: 'macOS app damaged warning dialog',
caption: 'macOS Gatekeeper warning example',
},
]}
/>
## Image slider
<BlogImageSlider
images={[
{
src: '/iptvnator/blog/iptvnator-v18.jpeg',
alt: 'IPTVnator v0.18 UI screenshot',
caption: 'Main app interface in v0.18.',
},
{
src: '/iptvnator/blog/macos-app-damaged.jpg',
alt: 'macOS app damaged warning dialog',
caption: 'Gatekeeper warning on unsigned app launch.',
},
{
src: '/iptvnator/blog/epg.png',
alt: 'EPG guide screenshot',
caption: 'Electronic Program Guide screen in IPTVnator.',
},
]}
/>
## Spoiler block
<Spoiler title="Show advanced troubleshooting note">
If launch issues persist, verify the app path and run `xattr -c` on the exact `.app` bundle location.
</Spoiler>
## FAQ accordion
<FaqAccordion
items={[
{
q: 'Can I use these components in regular .md files?',
a: 'No. Use .mdx when you want to render custom Astro components inside post content.',
},
{
q: 'Will draft posts appear on production blog pages?',
a: 'No. Posts with draft: true are excluded from listing and route generation.',
},
{
q: 'Can I mix markdown with components in one post?',
a: 'Yes. MDX supports regular markdown syntax and imported components together.',
},
]}
/>
## When to use MDX
- Use `.md` for plain posts
- Use `.mdx` when you want custom components, richer callouts, or advanced layout blocks
@@ -0,0 +1,58 @@
---
title: Understanding the Electronic Program Guide
description: Learn how IPTVnator uses XMLTV data to display current and upcoming programs alongside your live TV channels.
pubDate: 2026-02-14
author: 4gray
heroImage: /iptvnator/blog/epg.png
tags:
- tutorial
- epg
draft: false
---
import Alert from '../../components/blog/Alert.astro';
import PostButton from '../../components/blog/PostButton.astro';
One of the most useful features in IPTVnator is the built-in Electronic Program Guide (EPG). Here is how it works and how to set it up.
## What is EPG?
The Electronic Program Guide displays a schedule of current and upcoming TV programs for your channels. IPTVnator supports EPG data in the **XMLTV format**, which is the standard used by most IPTV providers.
<Alert type="info" title="Format tip">
Most providers expose EPG as `.xml` or compressed `.xml.gz` files.
</Alert>
## Setting up EPG
To enable EPG in IPTVnator:
1. **Open your playlist** and navigate to the Settings tab
2. **Add an EPG URL** — your IPTV provider should supply a URL ending in `.xml` or `.xml.gz`
3. **Refresh the EPG data** — IPTVnator will download and parse the program information in the background
The EPG data is parsed in a background worker thread, so it won't slow down the main application even with large files.
## EPG in action
Once configured, you will see:
- **Current program** displayed next to each channel in the channel list
- **Progress bar** showing how far through the current program you are
- **Program details** in the right sidebar when you select a channel
- **Upcoming schedule** with times and descriptions for future programs
## Channel matching
IPTVnator matches EPG data to channels using the `tvg-id` attribute from your M3U playlist. Make sure your playlist includes these IDs for the best EPG experience.
## Tips
- EPG files can be large — IPTVnator caches parsed data in the database (Electron)
- You can add multiple EPG sources per playlist
- The EPG automatically refreshes periodically to keep the schedule up to date
<PostButton
href="https://github.com/4gray/iptvnator"
label="See supported formats on GitHub"
/>
@@ -0,0 +1,47 @@
---
title: macOS ARM Users - Fix for "App is Damaged" Error
description: If IPTVnator shows "app is damaged and can't be opened" on Apple Silicon Macs, run one Terminal command to remove quarantine and launch safely.
pubDate: 2026-02-15
author: 4gray
heroImage: /iptvnator/blog/macos-app-damaged.jpg
tags:
- macos
- troubleshooting
- installation
draft: false
---
import Alert from '../../components/blog/Alert.astro';
import PostButton from '../../components/blog/PostButton.astro';
import TerminalBlock from '../../components/blog/TerminalBlock.astro';
If you are on macOS ARM (Apple Silicon) and IPTVnator shows **"app is damaged and can't be opened"**, use this quick fix.
## TL;DR
<TerminalBlock title="Terminal">
$ xattr -c /Applications/IPTVnator.app
</TerminalBlock>
## Why this happens
IPTVnator is currently not code-signed for macOS. After download, Gatekeeper adds a quarantine attribute, and macOS can block launch with a generic "damaged" message.
To distribute a fully signed and notarized macOS app, Apple requires an active Apple Developer subscription (currently **$99/year**). As an open-source project, IPTVnator does not currently use that signing pipeline.
## What the command does
The command above removes the quarantine attribute from the app bundle:
- It does **not** modify IPTVnator binaries
- It tells your Mac to trust the app you intentionally downloaded
- It is the standard local workaround for unsigned apps
<Alert type="warning" title="Safety note">
Only run this command for apps you trust and downloaded from a trusted source.
</Alert>
<PostButton
href="https://github.com/4gray/iptvnator/releases/latest"
label="Download from official GitHub releases"
/>
@@ -0,0 +1,137 @@
---
title: v0.18 - Release Notes
description: IPTVnator v0.18 brings a new playlist switcher UI, large EPG improvements, Xtream and Stalker updates, and multiple UX fixes across the app.
featured: false
pubDate: 2026-01-14
author: 4gray
heroImage: /iptvnator/blog/iptvnator-v18.jpeg
tags:
- release
- release-notes
- v0.18
draft: false
---
import Alert from '../../components/blog/Alert.astro';
import LinkCards from '../../components/blog/LinkCards.astro';
import ReleaseMeta from '../../components/blog/ReleaseMeta.astro';
import StatusPill from '../../components/blog/StatusPill.astro';
import ChangeTable from '../../components/blog/ChangeTable.astro';
IPTVnator **v0.18** is out.
<ReleaseMeta version="v0.18.0" releaseDate="January 14, 2026" channels={['Desktop', 'PWA']} />
## Support the project
- **GitHub Sponsors**: [https://github.com/sponsors/4gray](https://github.com/sponsors/4gray)
- **Buy me a coffee**: [https://ko-fi.com/4gray](https://ko-fi.com/4gray)
- **USDT (TRC20)**: `TLz5rTS7QTxYGfRH1Pm6Hs9fewqstDM47c`
<LinkCards
links={[
{
label: 'GitHub Sponsors',
href: 'https://github.com/sponsors/4gray',
hint: 'Support continued development.',
icon: 'sponsor',
},
{
label: 'Ko-fi',
href: 'https://ko-fi.com/4gray',
hint: 'One-time support for the project.',
icon: 'kofi',
},
{
label: 'Telegram',
href: 'https://t.me/iptvnator',
hint: 'Follow announcements and updates.',
icon: 'telegram',
},
]}
/>
## Summary
<ChangeTable
rows={[
{
area: 'General',
change: 'Unified playlist switcher and cleaner header controls.',
impact: 'Faster day-to-day navigation',
},
{
area: 'M3U',
change: 'Large EPG handling, progress popup, and restored quality selector.',
impact: 'More stable playback setup',
},
{
area: 'Xtream',
change: 'Category visibility controls and recently-added improvements.',
impact: 'Better catalog browsing',
},
{
area: 'Stalker',
change: 'Initial support for playlists requiring device IDs/signatures.',
impact: 'Broader provider compatibility',
},
]}
/>
## General
<StatusPill type="improved" />
- New playlist switcher UI: a unified and cleaner way to switch between playlists
- Global favorites moved to the header for quicker access
- Sort and filter options merged into a single header menu
## M3U
<StatusPill type="improved" />
- Improved EPG download and parsing to better handle large EPG files
- Added a popup dialog showing EPG download progress
- Restored quality selector for the Video.js player
## Xtream & Stalker
<StatusPill type="fixed" />
- Improved look and feel of the details view for VODs and TV shows
- Fixed opening favorite items with embedded players
## Xtream
<StatusPill type="new" />
- Category management: hide/show categories
- Display item counts per category
- New separate view for recently added items
- Recently added items are shown when no category is selected
- Fixed empty categories issue
- Restored import progress counter
## Stalker
<StatusPill type="beta" label="partial support" />
- Added support for playlists that require device IDs and signatures
- Some playlists are still not supported; more testing data is needed
<Alert type="warning" title="Before updating">
Please back up your playlists, credentials, URLs, and other important data.
</Alert>
## Download
<LinkCards
links={[
{
label: 'Download v0.18.0',
href: 'https://github.com/4gray/iptvnator/releases/tag/v0.18.0',
hint: 'Official release binaries and checksums.',
icon: 'download',
},
]}
/>
@@ -0,0 +1,205 @@
---
title: v0.19 - Release Notes
description: One of the biggest IPTVnator releases so far with major Home, M3U, Xtream, Stalker, and Remote Control improvements, plus many fixes and refactorings.
featured: true
pubDate: 2026-02-19
author: 4gray
heroImage: /iptvnator/blog/v0-19-xtream-category-grid.png
tags:
- release
- release-notes
- v0.19
draft: false
---
import Alert from '../../components/blog/Alert.astro';
import LinkCards from '../../components/blog/LinkCards.astro';
import ReleaseMeta from '../../components/blog/ReleaseMeta.astro';
import StatusPill from '../../components/blog/StatusPill.astro';
import ChangeTable from '../../components/blog/ChangeTable.astro';
import ImageGallery from '../../components/blog/ImageGallery.astro';
IPTVnator **v0.19** is out.
This is one of the biggest IPTVnator releases so far, with many fixes, refactorings, and new features. A lot of reported issues were addressed in this version.
Thank you to everyone who contributed ideas, bug reports, improvements, and PRs. Biggest kudos to everyone who supported the project with donations.
Let’s keep the momentum going and aim for more regular releases.
<ReleaseMeta
version="v0.19.0"
releaseDate="February 19, 2026"
channels={['Desktop', 'PWA']}
/>
## Support the project
- **GitHub Sponsors**: [https://github.com/sponsors/4gray](https://github.com/sponsors/4gray)
- **Buy me a coffee**: [https://ko-fi.com/4gray](https://ko-fi.com/4gray)
- **USDT (TRC20)**: `TLz5rTS7QTxYGfRH1Pm6Hs9fewqstDM47c`
<LinkCards
links={[
{
label: 'GitHub Sponsors',
href: 'https://github.com/sponsors/4gray',
hint: 'Support continued development.',
icon: 'sponsor',
},
{
label: 'Ko-fi',
href: 'https://ko-fi.com/4gray',
hint: 'One-time support for the project.',
icon: 'kofi',
},
{
label: 'Telegram',
href: 'https://t.me/iptvnator',
hint: 'Follow announcements and updates.',
icon: 'telegram',
},
]}
/>
## Highlights summary
<ChangeTable
rows={[
{
area: 'Home',
change: 'Drag-and-drop playlist sorting, global search upgrades, and improved recent items.',
impact: 'Faster navigation and discovery',
},
{
area: 'M3U',
change: 'Lazy loading for large playlists, favorites in lists, EPG catch-up support, resizable sidebar.',
impact: 'Smoother handling of big datasets',
},
{
area: 'Xtream & Stalker',
change: 'Download Manager, playback position persistence, list views, and sidebar improvements.',
impact: 'Better browsing and continuity',
},
{
area: 'Remote Control',
change: 'Xtream/Stalker support, EPG current-program info, channel-number switching.',
impact: 'More complete remote workflow',
},
]}
/>
## Home
<StatusPill type="new" />
- Added custom playlist sorting with drag-and-drop reordering.
- Added Global Search and Recent Items to the Home header.
- Improved Global Search:
- Enhanced UI with grouped and ungrouped views.
- Added option to include/exclude hidden categories.
- Improved globally recent items:
- Added Stalker support.
- Enhanced UI with grouped and ungrouped views.
## M3U
<StatusPill type="improved" />
- Added lazy loading and improved handling for large playlists.
- Added Favorites button directly in lists, including group views.
- Added lazy loading in group view.
- Improved global favorites behavior.
- Restored EPG expired programs for catch-up/archive support.
- Added resizable sidebar.
## Xtream
<StatusPill type="new" />
- Playback position is now persisted for started VOD and series content.
- Added Download Manager.
- Added sorting options for Live TV channels.
- Added search in VOD/series categories.
- Added sorting in VOD/series categories by name or date added.
- Improved search flow: when returning from results, search phrase and results are preserved.
- Added list view for series.
- Added resizable sidebar.
## Stalker
<StatusPill type="new" />
- Playback position is now persisted for started VOD and series content.
- Added Download Manager.
- Added EPG support for Live TV channels.
- When no category is selected, all VOD/series categories are now shown.
- Added list view for series.
- Added resizable sidebar.
- Replaced "Load more" buttons with infinite scroll for Live TV channels.
## Remote Control
<StatusPill type="improved" />
- Added support for Xtream and Stalker portals.
- Added EPG info for the current program.
- Added channel switching by entering channel number.
## Website & Blog
<StatusPill type="new" />
- Added a new landing page: [https://4gray.github.io/iptvnator/](https://4gray.github.io/iptvnator/)
- Added a new blog: [https://4gray.github.io/iptvnator/blog](https://4gray.github.io/iptvnator/blog)
## Screenshot gallery
<ImageGallery
columns={2}
images={[
{
src: '/iptvnator/blog/v0-19-series-episodes.png',
alt: 'Series episodes view with progress indicators and actions',
caption: 'Series episodes with progress and actions.',
},
{
src: '/iptvnator/blog/v0-19-download-manager.png',
alt: 'Download manager view with completed episodes',
caption: 'Download Manager with completed items.',
},
{
src: '/iptvnator/blog/v0-19-remote-control.png',
alt: 'Remote control screen with channel controls and numeric keypad',
caption: 'Remote Control with channel number entry.',
},
{
src: '/iptvnator/blog/v0-19-xtream-category-grid.png',
alt: 'Xtream category grid and content view',
caption: 'Xtream category and content browsing.',
},
]}
/>
<Alert type="warning" title="Before updating">
Please back up your playlists, credentials, URLs, and other important data.
</Alert>
## Download
<LinkCards
links={[
{
label: 'Download v0.19.0',
href: 'https://github.com/4gray/iptvnator/releases/tag/v0.19.0',
hint: 'Official binaries and release assets on GitHub.',
icon: 'download',
},
{
label: 'All Releases',
href: 'https://github.com/4gray/iptvnator/releases',
hint: 'Browse every IPTVnator release on GitHub.',
icon: 'github',
},
]}
/>
+41
View File
@@ -0,0 +1,41 @@
---
title: Introducing the IPTVnator Website
description: We are excited to launch the official IPTVnator website and developer blog. Stay tuned for release notes, tutorials, and roadmap updates.
pubDate: 2026-02-14
author: 4gray
heroImage: /iptvnator/blog/player.png
tags:
- announcement
draft: false
---
import Alert from '../../components/blog/Alert.astro';
import PostButton from '../../components/blog/PostButton.astro';
We are excited to launch the official IPTVnator website and developer blog.
<Alert type="success" title="What this means">
You now have one place for releases, tutorials, troubleshooting guides, and project updates.
</Alert>
## What is IPTVnator?
IPTVnator is a free, open-source IPTV player application that supports M3U/M3U8 playlists, Xtream Codes API, and Stalker portals. It runs as a desktop application (via Electron) and as a Progressive Web App directly in your browser.
## What to expect from this blog
This blog will be the home for:
- **Release notes** for new versions
- **Tutorials** on how to get the most out of IPTVnator
- **Roadmap updates** so you know what features are coming next
- **Technical deep dives** into the architecture and development process
## Get involved
IPTVnator is open source and we welcome contributions.
<PostButton
href="https://github.com/4gray/iptvnator"
label="View project on GitHub"
/>
+44
View File
@@ -0,0 +1,44 @@
---
import BaseHead from '../components/BaseHead.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import '../styles/global.css';
interface Props {
title: string;
description: string;
image?: string;
type?: 'website' | 'article';
noindex?: boolean;
keywords?: string[];
publishedTime?: string;
modifiedTime?: string;
jsonLd?: Record<string, unknown> | Array<Record<string, unknown>>;
}
const { title, description, image, type, noindex, keywords, publishedTime, modifiedTime, jsonLd } = Astro.props;
---
<!doctype html>
<html lang="en" class="dark">
<head>
<BaseHead
title={title}
description={description}
image={image}
type={type}
noindex={noindex}
keywords={keywords}
publishedTime={publishedTime}
modifiedTime={modifiedTime}
jsonLd={jsonLd}
/>
</head>
<body class="min-h-screen bg-surface-950 font-body text-surface-200">
<Header />
<main class="overflow-x-hidden">
<slot />
</main>
<Footer />
</body>
</html>
+155
View File
@@ -0,0 +1,155 @@
---
import BaseLayout from './BaseLayout.astro';
interface Props {
title: string;
description: string;
pubDate: Date;
updatedDate?: Date;
author: string;
tags?: string[];
heroImage?: string;
}
const { title, description, pubDate, updatedDate, author, tags = [], heroImage } = Astro.props;
const formattedDate = pubDate.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const formattedUpdatedDate = updatedDate?.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const AUTHOR_AVATAR_URL = 'https://avatars.githubusercontent.com/u/1503032';
const canonicalURL = new URL(Astro.url.pathname, Astro.site).toString();
const blogPostSchema = {
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: title,
description,
datePublished: pubDate.toISOString(),
dateModified: (updatedDate ?? pubDate).toISOString(),
author: {
'@type': 'Person',
name: author,
url: 'https://github.com/4gray',
},
publisher: {
'@type': 'Organization',
name: 'IPTVnator',
url: 'https://4gray.github.io/iptvnator/',
logo: {
'@type': 'ImageObject',
url: 'https://4gray.github.io/iptvnator/favicon.ico',
},
},
mainEntityOfPage: canonicalURL,
image: heroImage ? new URL(heroImage, Astro.site).toString() : undefined,
};
---
<BaseLayout
title={`${title} | IPTVnator Blog`}
description={description}
image={heroImage}
type="article"
publishedTime={pubDate.toISOString()}
modifiedTime={(updatedDate ?? pubDate).toISOString()}
keywords={tags}
jsonLd={blogPostSchema}
>
<article class="pt-36 pb-24">
<div class="relative mx-auto max-w-3xl px-6">
<div class="pointer-events-none absolute -top-16 -right-14 hidden w-44 rotate-[-8deg] opacity-30 mix-blend-screen md:block lg:w-52">
<img
src="/iptvnator/illustrations/blog-corner-signal-mark.svg"
alt=""
aria-hidden="true"
class="w-full object-contain"
loading="lazy"
/>
</div>
<!-- Back link -->
<a
href="/iptvnator/blog/"
class="inline-flex items-center gap-1.5 font-mono text-xs uppercase tracking-[0.1em] text-surface-500 transition-colors hover:text-accent-400"
>
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Back to blog
</a>
<!-- Header -->
<header class="mt-10">
<div class="flex flex-wrap items-center gap-2">
<time datetime={pubDate.toISOString()} class="font-mono text-xs text-surface-500">
{formattedDate}
</time>
{
tags.length > 0 && (
<>
<span class="text-surface-700">&middot;</span>
{tags.map((tag) => (
<span class="rounded-full border border-dashed border-accent-500/30 px-2.5 py-0.5 text-xs font-medium text-accent-400">
{tag}
</span>
))}
</>
)
}
</div>
<h1 class="mt-5 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl">
{title}
</h1>
<p class="mt-4 text-lg text-surface-400">{description}</p>
<div class="mt-6 flex items-center gap-3">
<img
src={AUTHOR_AVATAR_URL}
alt={`${author} avatar`}
class="h-8 w-8 rounded-full border border-dashed border-accent-500/30 object-cover"
loading="lazy"
/>
<div>
<div class="text-sm font-medium text-surface-100">{author}</div>
{
updatedDate && (
<div class="font-mono text-[11px] text-surface-500">
Updated {formattedUpdatedDate}
</div>
)
}
</div>
</div>
<hr class="section-divider mt-10" />
</header>
<!-- Hero image -->
{heroImage && (
<div class="relative mt-10">
<div class="absolute -inset-6 rounded-2xl bg-accent-500/[0.03] blur-2xl" />
<div class="relative overflow-hidden rounded-xl border border-surface-700/60 bg-surface-900 shadow-lg shadow-black/20">
<img src={heroImage} alt="" class="w-full" loading="eager" />
</div>
</div>
)}
<!-- Content -->
<div
class="prose prose-invert mt-12 max-w-none prose-headings:font-display prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-surface-100 prose-p:text-surface-300 prose-a:text-accent-400 prose-a:no-underline prose-a:decoration-accent-500/30 hover:prose-a:underline prose-strong:text-surface-100 prose-code:rounded prose-code:bg-surface-800/50 prose-code:px-1.5 prose-code:py-0.5 prose-code:font-mono prose-code:text-accent-300 prose-code:before:content-[''] prose-code:after:content-[''] prose-pre:border prose-pre:border-dashed prose-pre:border-surface-700/60 prose-pre:bg-surface-900/50 prose-blockquote:border-accent-500/30 prose-blockquote:text-surface-400 prose-hr:border-dashed prose-hr:border-surface-700/60"
>
<slot />
</div>
</div>
</article>
</BaseLayout>
+32
View File
@@ -0,0 +1,32 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
---
<BaseLayout
title="Page Not Found | IPTVnator"
description="The page you're looking for doesn't exist."
noindex={true}
>
<section class="flex min-h-[80vh] items-center justify-center pt-20">
<div class="mx-auto max-w-md px-6 text-center">
<img
src="/iptvnator/images/broken-tv.svg"
alt="Broken TV"
class="mx-auto h-48 w-48 opacity-40"
/>
<h1 class="mt-8 font-display text-5xl font-bold text-surface-50">404</h1>
<p class="mt-4 text-lg text-surface-400">
This page doesn't exist. Maybe the channel was removed from the playlist.
</p>
<a
href="/iptvnator/"
class="mt-8 inline-flex items-center gap-2.5 rounded-lg bg-accent-500 px-6 py-3 text-sm font-semibold text-surface-950 transition-all hover:bg-accent-400"
>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Go back home
</a>
</div>
</section>
</BaseLayout>
@@ -0,0 +1,30 @@
---
import { getCollection, render } from 'astro:content';
import BlogPost from '../../layouts/BlogPost.astro';
export async function getStaticPaths() {
const includeDrafts = import.meta.env.DEV || import.meta.env.PUBLIC_INCLUDE_DRAFTS === 'true';
const posts = await getCollection('blog');
return posts
.filter((post) => includeDrafts || !post.data.draft)
.map((post) => ({
params: { slug: post.id },
props: post,
}));
}
const post = Astro.props;
const { Content } = await render(post);
---
<BlogPost
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
updatedDate={post.data.updatedDate}
author={post.data.author}
tags={post.data.tags}
heroImage={post.data.heroImage}
>
<Content />
</BlogPost>
+74
View File
@@ -0,0 +1,74 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import FeaturedBlogPost from '../../components/FeaturedBlogPost.astro';
import BlogPostCard from '../../components/BlogPostCard.astro';
const includeDrafts = import.meta.env.DEV || import.meta.env.PUBLIC_INCLUDE_DRAFTS === 'true';
const posts = (await getCollection('blog'))
.filter((post) => includeDrafts || !post.data.draft)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
const featuredPost = posts.find((post) => post.data.featured) ?? posts[0];
const remainingPosts = featuredPost ? posts.filter((post) => post.id !== featuredPost.id) : [];
---
<BaseLayout
title="Blog | IPTVnator"
description="Release notes, tutorials, and project updates from the IPTVnator team."
keywords={['IPTVnator blog', 'IPTV release notes', 'EPG, Archive setup', 'Xtream Codes Player (XC)', 'Stalker Player Support (STB)']}
>
<section class="pt-36 pb-24">
<div class="mx-auto max-w-6xl px-6">
<!-- Header -->
<div class="max-w-2xl">
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">
Blog
</span>
<h1 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl">
Blog
</h1>
<p class="mt-4 text-lg text-surface-400">
Release notes, tutorials, and project updates.
</p>
</div>
{posts.length > 0 ? (
<>
{/* Featured post */}
{featuredPost && (
<div class="mt-14">
<FeaturedBlogPost
title={featuredPost.data.title}
description={featuredPost.data.description}
pubDate={featuredPost.data.pubDate}
slug={featuredPost.id}
tags={featuredPost.data.tags}
heroImage={featuredPost.data.heroImage}
/>
</div>
)}
{/* Remaining posts grid */}
{remainingPosts.length > 0 && (
<div class="mt-10 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{remainingPosts.map((post) => (
<BlogPostCard
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
slug={post.id}
tags={post.data.tags}
heroImage={post.data.heroImage}
/>
))}
</div>
)}
</>
) : (
<p class="mt-14 text-center text-surface-500">No posts yet. Check back soon!</p>
)}
</div>
</section>
</BaseLayout>
+132
View File
@@ -0,0 +1,132 @@
---
import { getCollection } from 'astro:content';
import BaseLayout from '../layouts/BaseLayout.astro';
import Hero from '../components/Hero.astro';
import FeatureGrid from '../components/FeatureGrid.astro';
import ScreenshotShowcase from '../components/ScreenshotShowcase.astro';
import DownloadCTA from '../components/DownloadCTA.astro';
import SupportSection from '../components/SupportSection.astro';
import FeaturedBlogPost from '../components/FeaturedBlogPost.astro';
import BlogPostCard from '../components/BlogPostCard.astro';
const includeDrafts = import.meta.env.DEV || import.meta.env.PUBLIC_INCLUDE_DRAFTS === 'true';
const allPosts = (await getCollection('blog'))
.filter((post) => includeDrafts || !post.data.draft)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
const featuredPost = allPosts.find((post) => post.data.featured) ?? allPosts[0];
const otherPosts = featuredPost ? allPosts.filter((post) => post.id !== featuredPost.id).slice(0, 2) : [];
const posts = featuredPost ? [featuredPost, ...otherPosts] : allPosts.slice(0, 3);
const homeSchema = [
{
'@context': 'https://schema.org',
'@type': 'WebSite',
name: 'IPTVnator',
url: 'https://4gray.github.io/iptvnator/',
description:
'A cross-platform IPTV player supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals.',
inLanguage: 'en',
},
{
'@context': 'https://schema.org',
'@type': 'SoftwareApplication',
name: 'IPTVnator',
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Windows, macOS, Linux, Web',
softwareVersion: '0.19.0',
url: 'https://4gray.github.io/iptvnator/',
downloadUrl: 'https://github.com/4gray/iptvnator/releases/latest',
author: {
'@type': 'Person',
name: '4gray',
url: 'https://github.com/4gray',
},
sameAs: [
'https://github.com/4gray/iptvnator',
'https://t.me/iptvnator',
'https://ko-fi.com/4gray',
],
description:
'Open-source IPTV player with M3U/M3U8, Xtream Codes API, and Stalker portal support.',
},
];
---
<BaseLayout
title="IPTVnator - Open Source IPTV Player"
description="A cross-platform IPTV player supporting M3U/M3U8 playlists, Xtream Codes API, and Stalker portals. Available as a desktop app and PWA."
keywords={['IPTV player', 'M3U', 'M3U8', 'Xtream Codes', 'Stalker portal', 'EPG', 'XMLTV', 'open-source']}
jsonLd={homeSchema}
>
<Hero />
<FeatureGrid />
<ScreenshotShowcase />
<DownloadCTA />
<SupportSection />
<!-- Recent Blog Posts -->
{
posts.length > 0 && (
<section class="py-28">
<hr class="section-divider mb-28" />
<div class="mx-auto max-w-6xl px-6">
<div class="flex items-end justify-between">
<div>
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">
Blog
</span>
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
Latest <em class="font-medium text-accent-400" style="font-style: italic;">updates</em>
</h2>
<p class="mt-4 text-lg text-surface-400">
Release notes, tutorials, and project updates.
</p>
</div>
<a
href="/iptvnator/blog/"
class="hidden items-center gap-1.5 font-mono text-xs uppercase tracking-[0.1em] text-accent-500 transition-colors hover:text-accent-400 sm:inline-flex"
>
View all posts
<svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</a>
</div>
{/* Featured post */}
{featuredPost && (
<div class="mt-12">
<FeaturedBlogPost
title={featuredPost.data.title}
description={featuredPost.data.description}
pubDate={featuredPost.data.pubDate}
slug={featuredPost.id}
tags={featuredPost.data.tags}
heroImage={featuredPost.data.heroImage}
/>
</div>
)}
{/* Other posts grid */}
{otherPosts.length > 0 && (
<div class="mt-8 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{otherPosts.map((post) => (
<BlogPostCard
title={post.data.title}
description={post.data.description}
pubDate={post.data.pubDate}
slug={post.id}
tags={post.data.tags}
heroImage={post.data.heroImage}
/>
))}
</div>
)}
</div>
</section>
)
}
</BaseLayout>
+447
View File
@@ -0,0 +1,447 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply scroll-smooth;
}
body {
@apply bg-surface-950 text-surface-200 antialiased font-body;
background-image:
radial-gradient(ellipse 80% 50% at 50% -20%, rgba(32, 168, 168, 0.07), transparent),
url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.025'/%3E%3C/svg%3E");
}
::selection {
@apply bg-accent-500/30 text-white;
}
::-webkit-scrollbar {
@apply w-1.5;
}
::-webkit-scrollbar-track {
@apply bg-transparent;
}
::-webkit-scrollbar-thumb {
@apply rounded-full bg-surface-600 hover:bg-surface-500;
}
}
@layer components {
.section-divider {
border: none;
border-top: 1px dashed theme('colors.surface.700');
@apply mx-auto max-w-5xl;
}
/* Stagger animation: items hidden by default, animate on scroll via IntersectionObserver */
.stagger-children > * {
@apply opacity-0;
}
.stagger-children.stagger-visible > * {
@apply animate-fade-up;
}
.stagger-children.stagger-visible > *:nth-child(1) { animation-delay: 0ms; }
.stagger-children.stagger-visible > *:nth-child(2) { animation-delay: 80ms; }
.stagger-children.stagger-visible > *:nth-child(3) { animation-delay: 160ms; }
.stagger-children.stagger-visible > *:nth-child(4) { animation-delay: 240ms; }
.stagger-children.stagger-visible > *:nth-child(5) { animation-delay: 320ms; }
.stagger-children.stagger-visible > *:nth-child(6) { animation-delay: 400ms; }
.stagger-children.stagger-visible > *:nth-child(7) { animation-delay: 480ms; }
.stagger-children.stagger-visible > *:nth-child(8) { animation-delay: 560ms; }
.stagger-children.stagger-visible > *:nth-child(9) { animation-delay: 640ms; }
.stagger-children.stagger-visible > *:nth-child(10) { animation-delay: 720ms; }
.stagger-children.stagger-visible > *:nth-child(11) { animation-delay: 800ms; }
.stagger-children.stagger-visible > *:nth-child(12) { animation-delay: 880ms; }
.card-dotted {
@apply border border-dashed border-surface-600/60 rounded-lg p-6 transition-all duration-300;
}
.card-dotted:hover {
@apply border-accent-500/40 bg-accent-500/[0.03];
}
/* ── TV-effect download button ── */
.btn-tv {
position: relative;
display: inline-flex;
overflow: hidden;
isolation: isolate;
border: 1px solid rgba(32, 168, 168, 0.3);
background: linear-gradient(135deg, rgba(32, 168, 168, 0.12) 0%, rgba(32, 168, 168, 0.06) 50%, rgba(32, 168, 168, 0.1) 100%);
background-size: 180% 180%;
background-position: 0% 50%;
box-shadow:
0 0 20px rgba(32, 168, 168, 0.15),
0 0 40px rgba(32, 168, 168, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.06),
inset 0 0 20px rgba(32, 168, 168, 0.03);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
animation: tvIdleGlow 2.1s ease-in-out infinite, tvSurfaceShift 2.8s linear infinite, tvButtonJitter 1.2s steps(2, end) infinite;
will-change: transform, box-shadow, background-position;
}
.btn-tv::before {
content: '';
position: absolute;
inset: 0;
z-index: 1;
opacity: 0.34;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 2px,
rgba(32, 168, 168, 0.03) 2px,
rgba(32, 168, 168, 0.03) 4px
);
transition: opacity 0.3s ease;
pointer-events: none;
animation: tvHorizontalRoll 2.2s linear infinite;
}
.btn-tv::after {
content: '';
position: absolute;
inset: 0;
z-index: 1;
opacity: 0.22;
pointer-events: none;
mix-blend-mode: screen;
background-image:
linear-gradient(180deg, rgba(32, 168, 168, 0) 0%, rgba(32, 168, 168, 0.1) 48%, rgba(32, 168, 168, 0) 100%),
repeating-linear-gradient(
0deg,
transparent 0px,
transparent 5px,
rgba(180, 255, 255, 0.045) 6px,
transparent 7px
);
background-size: 100% 100%, 100% 12px;
background-position: 0 0, 0 0;
animation: tvInterferenceShift 0.45s steps(14) infinite, tvNoSignalBurst 3.6s steps(1, end) infinite;
}
/* TV scanline sweep overlay */
.btn-tv .tv-scanline {
position: absolute;
inset: 0;
z-index: 2;
opacity: 0.75;
overflow: hidden;
pointer-events: none;
transition: opacity 0.3s ease;
}
.btn-tv .tv-scanline::after {
content: '';
position: absolute;
left: 0;
right: 0;
height: 40%;
background: linear-gradient(
180deg,
transparent,
rgba(32, 168, 168, 0.08) 40%,
rgba(32, 168, 168, 0.12) 50%,
rgba(32, 168, 168, 0.08) 60%,
transparent
);
animation: tvScan 1.1s linear infinite;
}
/* Static noise overlay */
.btn-tv .tv-noise {
position: absolute;
inset: -50%;
z-index: 3;
opacity: 0.08;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.4'/%3E%3C/svg%3E");
background-size: 128px 128px;
background-position: 0 0;
pointer-events: none;
transition: opacity 0.3s ease;
mix-blend-mode: overlay;
animation: tvNoiseDrift 0.55s steps(24) infinite;
}
.btn-tv .tv-noise::after {
content: '';
position: absolute;
top: 48%;
left: -38%;
width: 34%;
height: 1px;
border-radius: 9999px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(32, 168, 168, 0.5) 40%,
rgba(155, 255, 255, 0.8) 50%,
rgba(32, 168, 168, 0.5) 60%,
transparent 100%
);
box-shadow: 0 0 10px rgba(32, 168, 168, 0.35);
opacity: 0.95;
height: 2px;
animation: tvNoiseLineX 1.15s linear infinite;
}
/* CRT vignette corners */
.btn-tv .tv-vignette {
position: absolute;
inset: 0;
z-index: 4;
opacity: 0;
border-radius: inherit;
box-shadow: inset 0 0 60px rgba(0, 0, 0, 0.3);
pointer-events: none;
transition: opacity 0.4s ease;
}
/* Hover state: TV turns on */
.btn-tv:hover {
border-color: rgba(32, 168, 168, 0.5);
background: linear-gradient(135deg, rgba(32, 168, 168, 0.18) 0%, rgba(32, 168, 168, 0.08) 50%, rgba(32, 168, 168, 0.15) 100%);
box-shadow:
0 0 44px rgba(32, 168, 168, 0.45),
0 0 100px rgba(32, 168, 168, 0.22),
0 0 130px rgba(32, 168, 168, 0.12),
inset 0 1px 0 rgba(255, 255, 255, 0.08),
inset 0 0 30px rgba(32, 168, 168, 0.06);
transform: translateY(-2px) scale(1.055);
}
.btn-tv:hover::before {
opacity: 0.75;
}
.btn-tv:hover::after {
opacity: 0.45;
}
.btn-tv:hover .tv-scanline {
opacity: 1;
}
.btn-tv:hover .tv-noise {
opacity: 0.16;
animation: tvNoiseDrift 0.35s steps(30) infinite, tvFlicker 0.08s infinite;
}
.btn-tv:hover .tv-noise::after {
opacity: 1;
animation-duration: 0.65s;
}
.btn-tv:hover .tv-vignette {
opacity: 1;
}
/* Glitch text on hover */
.btn-tv:hover .btn-tv-text {
text-shadow:
2px 0 rgba(32, 168, 168, 0.3),
-2px 0 rgba(255, 100, 100, 0.15);
animation: tvTextGlitch 4s infinite;
}
.btn-tv .btn-tv-text {
animation: tvSubpixelDrift 8s ease-in-out infinite;
}
@keyframes tvTextGlitch {
0%, 93%, 100% {
text-shadow:
0 0 rgba(32, 168, 168, 0),
0 0 rgba(255, 100, 100, 0);
}
94% {
text-shadow:
2px 0 rgba(32, 168, 168, 0.4),
-2px 0 rgba(255, 100, 100, 0.2);
}
95% {
text-shadow:
-1px 0 rgba(32, 168, 168, 0.3),
1px 0 rgba(255, 100, 100, 0.15);
}
96% {
text-shadow:
3px 0 rgba(32, 168, 168, 0.5),
-3px 0 rgba(255, 100, 100, 0.25);
}
97% {
text-shadow:
0 0 rgba(32, 168, 168, 0),
0 0 rgba(255, 100, 100, 0);
}
}
@keyframes tvIdleGlow {
0%, 100% {
box-shadow:
0 0 18px rgba(32, 168, 168, 0.14),
0 0 36px rgba(32, 168, 168, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.05),
inset 0 0 18px rgba(32, 168, 168, 0.02);
}
50% {
box-shadow:
0 0 24px rgba(32, 168, 168, 0.2),
0 0 48px rgba(32, 168, 168, 0.07),
inset 0 1px 0 rgba(255, 255, 255, 0.07),
inset 0 0 24px rgba(32, 168, 168, 0.04);
}
}
@keyframes tvSurfaceShift {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
}
@keyframes tvButtonJitter {
0%, 100% {
transform: translate3d(0, 0, 0);
}
20% {
transform: translate3d(0.4px, -0.3px, 0);
}
40% {
transform: translate3d(-0.5px, 0.2px, 0);
}
60% {
transform: translate3d(0.3px, 0.1px, 0);
}
80% {
transform: translate3d(-0.2px, -0.2px, 0);
}
}
@keyframes tvNoiseDrift {
0% {
transform: translate3d(-1%, -0.5%, 0) scale(1);
background-position: 0 0;
}
50% {
transform: translate3d(1%, 0.8%, 0) scale(1.015);
background-position: 22px -18px;
}
100% {
transform: translate3d(-0.5%, -0.2%, 0) scale(1);
background-position: 44px -8px;
}
}
@keyframes tvNoiseLineX {
0% {
transform: translateX(0);
}
100% {
transform: translateX(420%);
}
}
@keyframes tvHorizontalRoll {
0% {
transform: translateY(0);
}
100% {
transform: translateY(20px);
}
}
@keyframes tvInterferenceShift {
0% {
transform: translate3d(0, 0, 0);
background-position: 0 0, 0 0;
}
100% {
transform: translate3d(0, 0, 0);
background-position: 0 0, 0 12px;
}
}
@keyframes tvNoSignalBurst {
0%, 60%, 100% {
filter: contrast(1) brightness(1);
transform: translate3d(0, 0, 0);
}
61% {
filter: contrast(2.1) brightness(1.35) saturate(1.15);
transform: translate3d(1.3%, -0.5%, 0);
}
62% {
filter: contrast(0.72) brightness(0.8) saturate(0.9);
transform: translate3d(-1.4%, 0.4%, 0);
}
63% {
filter: contrast(1.6) brightness(1.2) saturate(1.1);
transform: translate3d(0.6%, -0.2%, 0);
}
}
@keyframes tvSubpixelDrift {
0%, 100% {
transform: translate3d(0, 0, 0);
text-shadow:
0 0 rgba(32, 168, 168, 0),
0 0 rgba(255, 110, 110, 0);
}
45% {
transform: translate3d(0.1px, 0, 0);
text-shadow:
0.5px 0 rgba(32, 168, 168, 0.2),
-0.5px 0 rgba(255, 110, 110, 0.08);
}
55% {
transform: translate3d(-0.1px, 0, 0);
text-shadow:
0.4px 0 rgba(32, 168, 168, 0.16),
-0.4px 0 rgba(255, 110, 110, 0.06);
}
}
/* ── Carousel infinite marquee ── */
.carousel-track {
animation: marquee 40s linear infinite;
}
.carousel-track:hover {
animation-play-state: paused;
}
@keyframes marquee {
0% { transform: translateX(0); }
100% { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.carousel-track {
animation: none;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.btn-tv,
.btn-tv::before,
.btn-tv::after,
.btn-tv .tv-noise,
.btn-tv .tv-noise::after,
.btn-tv .tv-scanline::after,
.btn-tv .btn-tv-text,
.btn-tv:hover .btn-tv-text {
animation-duration: 16s !important;
}
}
}