Proyecto App Linux
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 573 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 743 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
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">·</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">·</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
@@ -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"
|
||||
/>
|
||||
@@ -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>
|
||||
@@ -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">·</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||