chore(website): add reusable blog components including IconFeatureGrid, ImageGallery, LinkCards, PostButton, ReleaseMeta, Spoiler, StatusPill, StepRail, TerminalBlock

- Introduced new components for blog posts to enhance UI and functionality.
- Added support for featured posts and draft visibility in blog collections.
- Created demo blog post to showcase new components and their usage.
- Updated existing blog posts to utilize new components and improve content presentation.
This commit is contained in:
4gray committed 2026-02-15 01:11:07 +01:00
1 parent d19ae82e72
commit 69db46e122
26 files changed
+898 -13

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 365 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

@@ -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,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>
+1
View File
@@ -9,6 +9,7 @@ const blog = defineCollection({
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),
@@ -0,0 +1,207 @@
---
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 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',
},
]}
/>
## 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
@@ -1,7 +1,7 @@
---
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
pubDate: 2026-02-14
author: 4gray
heroImage: /iptvnator/blog/epg.png
tags:
@@ -10,12 +10,19 @@ tags:
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:
@@ -41,8 +48,11 @@ IPTVnator matches EPG data to channels using the `tvg-id` attribute from your M3
## Tips
- EPG files can be large — IPTVnator caches parsed data in the database (Electron)
- 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
Check the [GitHub repository](https://github.com/4gray/iptvnator) for more information on supported EPG formats.
<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: true
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',
},
]}
/>
@@ -9,7 +9,14 @@ tags:
draft: false
---
We are excited to launch the official IPTVnator website and developer blog!
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?
@@ -26,4 +33,9 @@ This blog will be the home for:
## Get involved
IPTVnator is open source and we welcome contributions! Check out our [GitHub repository](https://github.com/4gray/iptvnator) to get started.
IPTVnator is open source and we welcome contributions.
<PostButton
href="https://github.com/4gray/iptvnator"
label="View project on GitHub"
/>
+2 -1
View File
@@ -3,9 +3,10 @@ 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) => !post.data.draft)
.filter((post) => includeDrafts || !post.data.draft)
.map((post) => ({
params: { slug: post.id },
props: post,
+5 -2
View File
@@ -4,11 +4,14 @@ 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) => !post.data.draft)
.filter((post) => includeDrafts || !post.data.draft)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
const [featuredPost, ...remainingPosts] = posts;
const featuredPost = posts.find((post) => post.data.featured) ?? posts[0];
const remainingPosts = featuredPost ? posts.filter((post) => post.id !== featuredPost.id) : [];
---
<BaseLayout
+8 -5
View File
@@ -9,12 +9,15 @@ import SupportSection from '../components/SupportSection.astro';
import FeaturedBlogPost from '../components/FeaturedBlogPost.astro';
import BlogPostCard from '../components/BlogPostCard.astro';
const posts = (await getCollection('blog'))
.filter((post) => !post.data.draft)
.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf())
.slice(0, 3);
const includeDrafts = import.meta.env.DEV || import.meta.env.PUBLIC_INCLUDE_DRAFTS === 'true';
const [featuredPost, ...otherPosts] = posts;
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);
---
<BaseLayout