chore(website): add support section with SVG illustration and footer watermark

- Introduced a new SVG illustration for supporting open-source software.
- Added a footer watermark image to enhance the visual design of the footer.
- Created a new SupportSection component to encourage user contributions with links to GitHub Stars, GitHub Sponsors, and Ko-fi.
- Updated the Hero component to include a mascot image and social links for GitHub, Telegram, and Ko-fi.
- Enhanced the FeatureGrid and Footer components with improved styling and layout adjustments.
- Updated blog post metadata for better formatting and added a disclaimer in the SupportSection.
- Improved global CSS styles for buttons and animations to create a more engaging user experience.

Entire-Checkpoint: b7a1c2f08179
This commit is contained in:
4gray committed 2026-02-15 00:33:42 +01:00
1 parent 59581bd890
commit d19ae82e72
15 files changed
+806 -29

No files matched your search

@@ -0,0 +1,95 @@
<svg xmlns="http://www.w3.org/2000/svg" width="600" height="600" viewBox="0 0 600 600" role="img" aria-label="Blog corner playlist signal mark">
<defs>
<linearGradient id="tealStroke" x1="360" y1="120" x2="600" y2="420" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20a8a8" stop-opacity="0.78"/>
<stop offset="1" stop-color="#20a8a8" stop-opacity="0.16"/>
</linearGradient>
<linearGradient id="neutralStroke" x1="340" y1="140" x2="600" y2="460" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#c7ced2" stop-opacity="0.14"/>
<stop offset="1" stop-color="#3c474a" stop-opacity="0.18"/>
</linearGradient>
<filter id="microGlow" x="-40%" y="-40%" width="180%" height="180%" color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation="1.25" result="b"/>
<feMerge>
<feMergeNode in="b"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Weighted to top-right; airy; rotates well -->
<g id="layer-motif" filter="url(#microGlow)" fill="none" stroke-linecap="round" stroke-linejoin="round">
<!-- Hint of a "player/playlist card" (no UI text, just geometry) -->
<g id="playlist-card">
<path d="M410 128
C402 128, 396 134, 396 142
L396 242
C396 250, 402 256, 410 256
L544 256
C552 256, 558 250, 558 242
L558 142
C558 134, 552 128, 544 128
Z"
stroke="url(#neutralStroke)" stroke-width="2.2" stroke-opacity="0.7"/>
<!-- Minimal "list rows" (very low contrast) -->
<path d="M422 166 H534" stroke="#c7ced2" stroke-opacity="0.10" stroke-width="2"/>
<path d="M422 192 H514" stroke="#c7ced2" stroke-opacity="0.08" stroke-width="2"/>
<path d="M422 218 H528" stroke="#c7ced2" stroke-opacity="0.09" stroke-width="2"/>
<!-- A small play-indicator wedge, abstract -->
<path d="M416 156 L416 176 L432 166 Z"
stroke="#20a8a8" stroke-opacity="0.00" fill="#20a8a8" fill-opacity="0.12"/>
</g>
<!-- "Antenna pin" (more like a signal marker than a tower) -->
<g id="signal-pin">
<path d="M520 270 L520 318" stroke="url(#tealStroke)" stroke-width="3" stroke-opacity="0.45"/>
<circle cx="520" cy="258" r="6" fill="#20a8a8" fill-opacity="0.26"/>
<path d="M505 328 L520 318 L535 328" stroke="#3c474a" stroke-opacity="0.30" stroke-width="5"/>
<path d="M505 328 L520 318 L535 328" stroke="#20a8a8" stroke-opacity="0.12" stroke-width="2"/>
</g>
<!-- Signal sweep arcs (corner-friendly) -->
<g id="signal-sweep">
<path d="M470 86
C545 120, 590 190, 598 278"
stroke="url(#tealStroke)" stroke-width="2.6" stroke-opacity="0.34"/>
<path d="M492 112
C548 140, 580 196, 586 266"
stroke="#c7ced2" stroke-width="2" stroke-opacity="0.10"/>
<path d="M448 64
C545 106, 600 196, 608 312"
stroke="#20a8a8" stroke-width="2" stroke-opacity="0.16"/>
</g>
<!-- One thin connector line (ties card → pin) -->
<g id="connector">
<path d="M468 252 C480 270, 495 288, 520 318"
stroke="#3c474a" stroke-opacity="0.28" stroke-width="2"/>
<path d="M468 252 C480 270, 495 288, 520 318"
stroke="#20a8a8" stroke-opacity="0.09" stroke-width="1"/>
</g>
<!-- Sparse “stars” / points -->
<g id="points" fill="none">
<circle cx="576" cy="110" r="2.1" fill="#20a8a8" fill-opacity="0.18"/>
<circle cx="548" cy="70" r="1.6" fill="#c7ced2" fill-opacity="0.10"/>
<circle cx="592" cy="170" r="1.7" fill="#c7ced2" fill-opacity="0.09"/>
<circle cx="560" cy="236" r="1.8" fill="#20a8a8" fill-opacity="0.13"/>
<circle cx="430" cy="286" r="1.6" fill="#c7ced2" fill-opacity="0.08"/>
<!-- Tiny diamond point -->
<path d="M548 312 l6 6 l-6 6 l-6-6 Z" fill="#20a8a8" fill-opacity="0.08"/>
</g>
<!-- Subtle corner framing stroke (very airy, helps “corner” feel) -->
<g id="corner-frame">
<path d="M600 88 C560 92, 540 104, 520 132" stroke="#3c474a" stroke-opacity="0.16" stroke-width="2"/>
<path d="M600 120 C566 124, 546 136, 532 156" stroke="#20a8a8" stroke-opacity="0.08" stroke-width="2"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,50 @@
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="120" viewBox="0 0 400 120" role="img" aria-label="Footer broadcast pattern strip light (tileable)">
<defs>
<filter id="microGlow" x="-40%" y="-40%" width="180%" height="180%" color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation="0.8" result="b"/>
<feMerge>
<feMergeNode in="b"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Very minimal rails -->
<g id="layer-rails" fill="none" stroke-linecap="round">
<path d="M0 24 H400" stroke="#3c474a" stroke-opacity="0.14" stroke-width="1"/>
<path d="M0 96 H400" stroke="#3c474a" stroke-opacity="0.12" stroke-width="1"/>
</g>
<!-- Few ticks -->
<g id="layer-ticks" fill="none" stroke-linecap="round">
<g stroke="#c7ced2" stroke-opacity="0.07" stroke-width="1">
<path d="M0 24 V32"/><path d="M80 24 V32"/><path d="M160 24 V32"/><path d="M240 24 V32"/><path d="M320 24 V32"/><path d="M400 24 V32"/>
<path d="M40 96 V88"/><path d="M200 96 V88"/><path d="M360 96 V88"/>
</g>
<!-- Single tiny wave notch -->
<path d="M170 62 c8 -6 16 -6 24 0 c8 6 16 6 24 0"
stroke="#20a8a8" stroke-opacity="0.07" stroke-width="1.5" fill="none" filter="url(#microGlow)"/>
</g>
<!-- One small dial hint -->
<g id="layer-dial" fill="none" stroke-linecap="round" stroke-linejoin="round" filter="url(#microGlow)">
<g transform="translate(340 60)">
<circle cx="0" cy="0" r="9" stroke="#3c474a" stroke-opacity="0.16" stroke-width="1.2"/>
<path d="M0 -9 V-12" stroke="#c7ced2" stroke-opacity="0.07" stroke-width="1"/>
<circle cx="0" cy="0" r="2" fill="#20a8a8" fill-opacity="0.07"/>
</g>
</g>
<!-- Short dashed connector -->
<g id="layer-connector" fill="none" stroke-linecap="round">
<path d="M70 60 H130" stroke="#c7ced2" stroke-opacity="0.06" stroke-width="1" stroke-dasharray="3 7"/>
</g>
<!-- Sparse dots -->
<g id="layer-dots">
<circle cx="220" cy="24" r="1.0" fill="#20a8a8" fill-opacity="0.06"/>
<circle cx="120" cy="96" r="1.0" fill="#c7ced2" fill-opacity="0.05"/>
<circle cx="280" cy="78" r="0.9" fill="#3c474a" fill-opacity="0.12"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

@@ -0,0 +1,158 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="800" viewBox="0 0 1920 800" role="img" aria-label="Ambient broadcast waves background motif">
<defs>
<!-- Core palette -->
<linearGradient id="tealFade" x1="1400" y1="80" x2="780" y2="720" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#20a8a8" stop-opacity="0.22"/>
<stop offset="0.55" stop-color="#20a8a8" stop-opacity="0.07"/>
<stop offset="1" stop-color="#20a8a8" stop-opacity="0"/>
</linearGradient>
<radialGradient id="ringFade" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse"
gradientTransform="translate(1535 400) rotate(90) scale(420 740)">
<stop offset="0" stop-color="#20a8a8" stop-opacity="0.10"/>
<stop offset="0.6" stop-color="#20a8a8" stop-opacity="0.04"/>
<stop offset="1" stop-color="#20a8a8" stop-opacity="0"/>
</radialGradient>
<linearGradient id="scanFade" x1="0" y1="0" x2="0" y2="800" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#c7ced2" stop-opacity="0.00"/>
<stop offset="0.45" stop-color="#c7ced2" stop-opacity="0.06"/>
<stop offset="1" stop-color="#c7ced2" stop-opacity="0.00"/>
</linearGradient>
<!-- Subtle glow (kept restrained for 5–20% overall opacity usage) -->
<filter id="softGlow" x="-30%" y="-30%" width="160%" height="160%" color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
<feColorMatrix in="blur" type="matrix"
values="1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 0.55 0" result="glow"/>
<feMerge>
<feMergeNode in="glow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="microGlow" x="-40%" y="-40%" width="180%" height="180%" color-interpolation-filters="sRGB">
<feGaussianBlur in="SourceGraphic" stdDeviation="1.1" result="b"/>
<feMerge>
<feMergeNode in="b"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<!-- Transparent background: no rect fill -->
<!-- Layer: scanline bands (subtle, keeps text readability) -->
<g id="layer-scanlines" opacity="0.55">
<!-- Wide soft bands -->
<path d="M0 158H1920v38H0z" fill="url(#scanFade)" opacity="0.22"/>
<path d="M0 298H1920v26H0z" fill="url(#scanFade)" opacity="0.16"/>
<path d="M0 460H1920v30H0z" fill="url(#scanFade)" opacity="0.14"/>
<path d="M0 610H1920v42H0z" fill="url(#scanFade)" opacity="0.18"/>
<!-- Thin scanline-inspired hairlines -->
<g opacity="0.16" fill="#c7ced2">
<rect x="0" y="214" width="1920" height="1"/>
<rect x="0" y="238" width="1920" height="1"/>
<rect x="0" y="512" width="1920" height="1"/>
<rect x="0" y="536" width="1920" height="1"/>
<rect x="0" y="684" width="1920" height="1"/>
</g>
</g>
<!-- Layer: broadcast wave arcs (asymmetric weight on right) -->
<g id="layer-waves" filter="url(#softGlow)">
<g fill="none" stroke="url(#tealFade)" stroke-linecap="round" stroke-linejoin="round">
<!-- Primary sweeping arcs -->
<path d="M1040 120
C1320 90, 1560 150, 1750 280
C1900 382, 1940 520, 1815 640
C1690 760, 1450 780, 1240 710"
stroke-width="2.2" opacity="0.62"/>
<path d="M980 170
C1250 130, 1500 190, 1700 318
C1860 420, 1895 540, 1782 642
C1668 745, 1450 760, 1260 704"
stroke-width="1.6" opacity="0.48"/>
<path d="M1140 70
C1440 35, 1720 120, 1890 290
C2020 420, 1990 585, 1840 690
C1690 796, 1405 805, 1185 710"
stroke-width="1.2" opacity="0.34"/>
<!-- Secondary inner arcs for layered depth -->
<path d="M1245 235
C1440 205, 1605 255, 1730 350
C1830 426, 1850 520, 1770 595
C1690 670, 1520 682, 1375 630"
stroke-width="1.4" opacity="0.42"/>
<path d="M1315 300
C1490 280, 1625 330, 1715 402
C1795 466, 1800 532, 1735 585
C1670 638, 1535 640, 1425 604"
stroke-width="1.1" opacity="0.30"/>
</g>
<!-- Low-contrast charcoal support curve (adds CRT geometry without stealing focus) -->
<path id="support-curve"
d="M720 690
C980 610, 1180 560, 1400 520
C1560 492, 1730 480, 1920 490"
fill="none" stroke="#0c1114" stroke-opacity="0.35" stroke-width="2" stroke-linecap="round"/>
</g>
<!-- Layer: radial rings (right side, very restrained) -->
<g id="layer-rings" opacity="0.9">
<g fill="none" stroke="url(#ringFade)" stroke-width="1.2" filter="url(#microGlow)">
<circle cx="1535" cy="400" r="118" opacity="0.36"/>
<circle cx="1535" cy="400" r="178" opacity="0.26"/>
<circle cx="1535" cy="400" r="248" opacity="0.18"/>
<circle cx="1535" cy="400" r="320" opacity="0.12"/>
</g>
<!-- Offset subtle ring cluster for asymmetry -->
<g fill="none" stroke="#20a8a8" stroke-opacity="0.10" stroke-width="1" filter="url(#microGlow)">
<ellipse cx="1705" cy="290" rx="92" ry="62" opacity="0.35"/>
<ellipse cx="1705" cy="290" rx="140" ry="96" opacity="0.22"/>
</g>
</g>
<!-- Layer: nodes & dots (kept sparse) -->
<g id="layer-nodes" filter="url(#microGlow)">
<!-- Main node cluster -->
<g>
<circle cx="1688" cy="402" r="3.2" fill="#20a8a8" fill-opacity="0.38"/>
<circle cx="1622" cy="520" r="2.6" fill="#20a8a8" fill-opacity="0.28"/>
<circle cx="1748" cy="312" r="2.4" fill="#20a8a8" fill-opacity="0.24"/>
<circle cx="1586" cy="350" r="1.9" fill="#c7ced2" fill-opacity="0.18"/>
<circle cx="1765" cy="520" r="1.8" fill="#c7ced2" fill-opacity="0.14"/>
</g>
<!-- A few distant dots to balance without clutter -->
<g opacity="0.55">
<circle cx="1140" cy="250" r="1.6" fill="#20a8a8" fill-opacity="0.16"/>
<circle cx="980" cy="560" r="1.5" fill="#c7ced2" fill-opacity="0.10"/>
<circle cx="860" cy="340" r="1.4" fill="#20a8a8" fill-opacity="0.12"/>
</g>
<!-- Thin connector hints (barely there) -->
<g fill="none" stroke="#20a8a8" stroke-opacity="0.10" stroke-width="1" stroke-linecap="round">
<path d="M1688 402 L1622 520" opacity="0.35"/>
<path d="M1688 402 L1748 312" opacity="0.25"/>
<path d="M1622 520 L1765 520" opacity="0.18"/>
</g>
</g>
<!-- Layer: left-side negative-space soft vignette (improves text readability) -->
<g id="layer-negative-space">
<linearGradient id="leftVignette" x1="0" y1="0" x2="700" y2="0" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0b0f12" stop-opacity="0.26"/>
<stop offset="0.75" stop-color="#0b0f12" stop-opacity="0.10"/>
<stop offset="1" stop-color="#0b0f12" stop-opacity="0"/>
</linearGradient>
<rect x="0" y="0" width="760" height="800" fill="url(#leftVignette)"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6.9 KiB

@@ -0,0 +1,56 @@
<svg width="800" height="800" viewBox="0 0 800 800" fill="none" xmlns="http://www.w3.org/2000/svg">
<title>support-open-source-signal-isometric</title>
<desc>Isometric architectural core representing the structural support of open-source software.</desc>
<!-- Central Isometric Core (The Engine) -->
<g id="isometric-core" transform="translate(400, 420)">
<!-- Bottom Plate -->
<path d="M-120 0 L0 60 L120 0 L0 -60 Z" fill="#1a1f21" stroke="#3c474a" stroke-width="2" />
<!-- Vertical Pillars (Structure) -->
<g stroke="#3c474a" stroke-width="2" stroke-linecap="round">
<path d="M-80 -20 V-100" />
<path d="M80 -20 V-100" />
<path d="M0 20 V-60" />
</g>
<!-- The Floating Heart-Core (The Pulse) -->
<g id="core-pulse" transform="translate(0, -90)">
<!-- Isometric Cube/Diamond Representation -->
<path d="M-40 0 L0 20 L40 0 L0 -20 Z" fill="#20a8a8" fill-opacity="0.2" stroke="#20a8a8" stroke-width="3">
<animate attributeName="fill-opacity" values="0.2;0.5;0.2" dur="3s" repeatCount="indefinite" />
</path>
<path d="M-40 0 V30 L0 50 V20 Z" fill="#20a8a8" fill-opacity="0.1" stroke="#20a8a8" stroke-width="2" />
<path d="M40 0 V30 L0 50 V20 Z" fill="#20a8a8" fill-opacity="0.05" stroke="#20a8a8" stroke-width="2" />
<!-- Internal Heart Motif (Simplified for Isometric) -->
<path d="M-10 -5 Q0 -15 10 -5 L0 10 Z" fill="#4fd1d1" opacity="0.8">
<animate attributeName="opacity" values="0.8;0.3;0.8" dur="1.5s" repeatCount="indefinite" />
</path>
</g>
<!-- Converging "Support" Streams -->
<g id="data-streams" stroke="#20a8a8" stroke-width="1.5" opacity="0.4">
<path d="M-200 -100 L-100 -50" stroke-dasharray="4 8" />
<path d="M200 -100 L100 -50" stroke-dasharray="4 8" />
<path d="M-150 100 L-80 50" />
<path d="M150 100 L80 50" />
<!-- Stream End Nodes -->
<circle cx="-100" cy="-50" r="3" fill="#20a8a8" />
<circle cx="100" cy="-50" r="3" fill="#20a8a8" />
</g>
</g>
<!-- Framing Elements -->
<g id="frames" stroke="#3c474a" stroke-width="1" opacity="0.2">
<circle cx="400" cy="400" r="280" />
<path d="M200 200 L150 150 M600 200 L650 150 M200 600 L150 650 M600 600 L650 650" />
</g>
<!-- Accent Highlights -->
<circle cx="400" cy="330" r="4" fill="#4fd1d1" opacity="0.6">
<animate attributeName="r" values="4;6;4" dur="3s" repeatCount="indefinite" />
</circle>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

@@ -69,7 +69,7 @@ const carouselFeatures = [
<section id="features" class="relative py-28">
<hr class="section-divider mb-28" />
<div class="mx-auto max-w-6xl px-6">
<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">
+44 -3
View File
@@ -1,4 +1,6 @@
---
import footerWatermark from '../assets/footer-watermark.png';
const socialLinks = [
{
label: 'GitHub',
@@ -13,7 +15,7 @@ const socialLinks = [
{
label: 'Bluesky',
href: 'https://bsky.app/profile/4gray.bsky.social',
icon: 'M12 2C6.477 2 2 6.477 2 12s4.477 10 10 10 10-4.477 10-10S17.523 2 12 2zm3.5 14.5c-1.5 0-2.7-.8-3.5-2-.8 1.2-2 2-3.5 2C6.6 16.5 5 14.9 5 13c0-3 3.5-6.5 7-9.5 3.5 3 7 6.5 7 9.5 0 1.9-1.6 3.5-3.5 3.5z',
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',
@@ -23,8 +25,21 @@ const socialLinks = [
];
---
<footer class="border-t border-dashed border-surface-700/60">
<div class="mx-auto max-w-6xl px-6 py-16">
<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">
@@ -71,3 +86,29 @@ const socialLinks = [
</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>
+62
View File
@@ -1,13 +1,57 @@
---
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-full min-w-[1100px] object-cover md:h-[22rem] 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 -->
@@ -90,6 +134,24 @@ import heroImg from '../assets/screenshots/hero-dark.png';
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 -->
@@ -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>
+8 -5
View File
@@ -1,10 +1,13 @@
---
title: 'Understanding the Electronic Program Guide'
description: 'Learn how IPTVnator uses XMLTV data to display current and upcoming programs alongside your live TV channels.'
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-15
author: '4gray'
heroImage: '/iptvnator/blog/epg.png'
tags: ['tutorial', 'epg']
author: 4gray
heroImage: /iptvnator/blog/epg.png
tags:
- tutorial
- epg
draft: false
---
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.
+7 -5
View File
@@ -1,10 +1,12 @@
---
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.'
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']
author: 4gray
heroImage: /iptvnator/blog/player.png
tags:
- announcement
draft: false
---
We are excited to launch the official IPTVnator website and developer blog!
+19 -4
View File
@@ -24,11 +24,23 @@ const formattedUpdatedDate = updatedDate?.toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
});
const AUTHOR_AVATAR_URL = 'https://avatars.githubusercontent.com/u/1503032';
---
<BaseLayout title={`${title} | IPTVnator Blog`} description={description}>
<article class="pt-36 pb-24">
<div class="mx-auto max-w-3xl px-6">
<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/"
@@ -67,9 +79,12 @@ const formattedUpdatedDate = updatedDate?.toLocaleDateString('en-US', {
<p class="mt-4 text-lg text-surface-400">{description}</p>
<div class="mt-6 flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full border border-dashed border-accent-500/30 bg-accent-500/10 font-mono text-sm font-bold text-accent-400">
{author.charAt(0).toUpperCase()}
</div>
<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>
{
+2
View File
@@ -5,6 +5,7 @@ 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';
@@ -24,6 +25,7 @@ const [featuredPost, ...otherPosts] = posts;
<FeatureGrid />
<ScreenshotShowcase />
<DownloadCTA />
<SupportSection />
<!-- Recent Blog Posts -->
{
+218 -11
View File
@@ -68,16 +68,21 @@
/* ── 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 {
@@ -85,7 +90,7 @@
position: absolute;
inset: 0;
z-index: 1;
opacity: 0;
opacity: 0.34;
background: repeating-linear-gradient(
0deg,
transparent,
@@ -95,6 +100,29 @@
);
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 */
@@ -102,7 +130,7 @@
position: absolute;
inset: 0;
z-index: 2;
opacity: 0;
opacity: 0.75;
overflow: hidden;
pointer-events: none;
transition: opacity 0.3s ease;
@@ -122,7 +150,7 @@
rgba(32, 168, 168, 0.08) 60%,
transparent
);
animation: tvScan 3s linear infinite;
animation: tvScan 1.1s linear infinite;
}
/* Static noise overlay */
@@ -130,12 +158,36 @@
position: absolute;
inset: -50%;
z-index: 3;
opacity: 0;
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 */
@@ -155,16 +207,20 @@
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 30px rgba(32, 168, 168, 0.3),
0 0 60px rgba(32, 168, 168, 0.1),
0 0 100px rgba(32, 168, 168, 0.05),
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: scale(1.02);
transform: translateY(-2px) scale(1.055);
}
.btn-tv:hover::before {
opacity: 1;
opacity: 0.75;
}
.btn-tv:hover::after {
opacity: 0.45;
}
.btn-tv:hover .tv-scanline {
@@ -172,8 +228,13 @@
}
.btn-tv:hover .tv-noise {
opacity: 0.04;
animation: tvFlicker 0.15s infinite;
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 {
@@ -188,6 +249,10 @@
animation: tvTextGlitch 4s infinite;
}
.btn-tv .btn-tv-text {
animation: tvSubpixelDrift 8s ease-in-out infinite;
}
@keyframes tvTextGlitch {
0%, 93%, 100% {
text-shadow:
@@ -216,6 +281,137 @@
}
}
@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;
@@ -236,5 +432,16 @@
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;
}
}
}