Files
iptvnator/apps/website/src/components/FeatureGrid.astro
T
4grayandClaude Fable 5.1 baba0529ef feat(website): rebuild the hero, features, download and support sections (#1551)
* feat(website): rebuild the hero, features, download and support sections

Third landing redesign PR: the home page now speaks the app's own
language instead of a template's.

- Hero: left-aligned headline, a primary button that follows the
  visitor's OS (server-rendered fallback goes to /download/), an "All
  platforms" button and a text link for self-hosting. Project numbers
  (stars, downloads, languages, license) are set in the page's own
  typography from the GitHub API at build time (`lib/github-stats.ts`);
  anything the build cannot resolve is left out rather than faked. A
  small "Now playing" card with fictional demo content replaces the
  mascot, badges and social chips; the screenshot is lit by a blurred
  copy of itself, like the player's ambient mode.
- Features: a bento of interface fragments (EPG rows with progress,
  posters with a resume bar, a download queue with a REC dot, a remote
  D-pad) instead of numbered cards with icon watermarks and a marquee.
  The marquee CSS is gone.
- Download: one row per platform with the release's file names and a
  "Detected" badge for the visitor's OS (`lib/detect-platform.ts`),
  package-manager commands in the same panel.
- Support becomes a single row; the disclaimer moves into the footer,
  which also gets the mascot and a link list.
- Home sections drop the decorative eyebrows and the divider rules; the
  unused broadcast-wave, support-signal and watermark illustrations are
  removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(website): cover the rebuilt home sections and the OS-aware download CTA

`website-home-sections.test.mjs` (in the website test target) reads the
built home page — generic hero CTA to /download/, project facts, no
badge images or dashed borders, every feature page linked from the
bento, one download row per platform with release file names and the
Detected badge hidden, the disclaimer and mascot in the footer — and
then drives it in Chromium under Windows, macOS, Linux and Android user
agents: the primary button and the Detected row follow the platform,
the phone keeps the generic markup, and the copy button writes the
command to the clipboard.

`detectPlatform()` now consults the user-agent string before the
client-hints platform and the deprecated `navigator.platform`, so the
source a visitor's tools actually change decides first; silent sources
fall through instead of vetoing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(website): keep iPads on the generic download path

iPadOS asks for desktop sites with a Macintosh user agent and a
`MacIntel` platform, so the OS guess sent iPad visitors to the macOS
installers. A "mac" verdict is now trusted only on a device without
touch points: no Mac has a touch screen, every iPad reports several.
The browser test adds an iPad-in-desktop-mode row to the generic-device
matrix next to the Android phone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:51:46 +02:00

247 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
/**
* Features as a bento of small interface fragments. Each cell shows the piece
* of the app it talks about — EPG rows with progress, posters with a resume
* bar, a download queue with a REC dot — instead of an icon metaphor. Every
* channel, program and title below is fictional demo content.
*/
const sources = [
{ label: 'M3U / M3U8', href: '/iptvnator/features/m3u-player/' },
{ label: 'Xtream Codes', href: '/iptvnator/features/xtream-codes-player/' },
{ label: 'Stalker portal', href: '/iptvnator/features/stalker-portal-player/' },
];
const guide = [
{ badge: 'AUR', channel: 'Aurora News', program: 'Evening edition', progress: 26, ends: '19:30' },
{ badge: 'FSS', channel: 'Fieldside Sports', program: 'Late Kickoff · match of the day', progress: 71, ends: '22:15' },
{ badge: 'KLB', channel: 'Kitchen Lab', program: 'Sourdough, second attempt', progress: 44, ends: '20:00' },
];
const posters = [
{ title: 'Signal Lost', progress: 62, tint: 'linear-gradient(160deg, #1d4f52, #0c1a1c)' },
{ title: 'Night Desk', progress: 18, tint: 'linear-gradient(160deg, #5a3a24, #1a120c)' },
{ title: 'Harbor Lights', progress: 0, tint: 'linear-gradient(160deg, #3b2f6b, #12101c)' },
];
const players = ['Built-in', 'Embedded MPV', 'MPV', 'VLC'];
const platforms = [
{ name: 'macOS', detail: 'Intel · Apple Silicon' },
{ name: 'Windows', detail: 'installer · portable' },
{ name: 'Linux', detail: 'AppImage · deb · rpm · Snap · Flatpak' },
{ name: 'Web', detail: 'Docker · PWA' },
];
const cellClass =
'group relative flex flex-col gap-3 overflow-hidden rounded-xl border border-surface-800 bg-surface-900/40 p-6 transition-colors duration-300';
const linkCellClass = `${cellClass} hover:border-surface-600 hover:bg-surface-900/70`;
---
<section id="features" class="relative py-24 sm:py-28">
<div class="relative z-10 mx-auto max-w-6xl px-6">
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<h2 class="max-w-md font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
Built like a TV app, not a file browser
</h2>
<p class="max-w-sm text-base text-surface-400 sm:text-right">
Everything a living-room player needs, on a desktop and in the browser.
</p>
</div>
<div class="stagger-children mt-12 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-12" data-stagger>
<!-- Sources -->
<div class={`${cellClass} sm:col-span-2 lg:col-span-7`}>
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-50">Three kinds of sources, one library</h3>
<p class="max-w-md text-sm leading-relaxed text-surface-400">
Add an M3U file or URL, an Xtream Codes account or a Stalker portal. Paste the message your provider
sent and IPTVnator fills in the form.
</p>
<div class="mt-auto pt-2">
<div class="flex flex-wrap gap-2">
{
sources.map((source) => (
<a
href={source.href}
class="rounded-md border border-surface-700 bg-surface-900 px-2.5 py-1.5 font-mono text-xs text-surface-300 transition-colors hover:border-accent-500 hover:text-accent-400"
>
{source.label}
</a>
))
}
</div>
<div class="mt-3 flex items-center gap-3 rounded-lg border border-surface-700 bg-surface-900 px-3 py-2.5 font-mono text-xs" aria-hidden="true">
<span class="text-surface-500">URL</span>
<span class="truncate text-surface-200">https://example.tv/get.php?type=m3u_plus</span>
<span class="ml-auto shrink-0 text-accent-400">Add →</span>
</div>
</div>
</div>
<!-- Program guide -->
<a href="/iptvnator/features/epg/" class={`${linkCellClass} lg:col-span-5`}>
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-50">A program guide that follows you</h3>
<p class="text-sm leading-relaxed text-surface-400">
XMLTV or provider EPG: the current program in every list, a timeline under the player.
</p>
<ul class="mt-auto grid gap-2.5 pt-2" aria-hidden="true">
{
guide.map((row) => (
<li class="grid grid-cols-[32px_minmax(0,1fr)_auto] items-center gap-3">
<span class="grid h-8 w-8 place-items-center rounded-md border border-surface-800 bg-surface-850 font-mono text-[10px] text-surface-500">{row.badge}</span>
<span class="min-w-0">
<span class="block truncate text-[13px] font-medium text-surface-100">{row.channel}</span>
<span class="block truncate text-xs text-surface-500">{row.program}</span>
<span class="mt-1.5 block h-[3px] overflow-hidden rounded-full bg-surface-800">
<span class="block h-full bg-accent-500" style={`width: ${row.progress}%`} />
</span>
</span>
<span class="font-mono text-[11px] tabular-nums text-surface-500">{row.ends}</span>
</li>
))
}
</ul>
</a>
<!-- Movies and series -->
<a href="/iptvnator/features/xtream-codes-player/" class={`${linkCellClass} lg:col-span-4`}>
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-50">Movies and series</h3>
<p class="text-sm leading-relaxed text-surface-400">Posters, seasons, resume where you stopped, TMDB details when you want them.</p>
<div class="mt-auto flex gap-2 pt-2" aria-hidden="true">
{
posters.map((poster) => (
<div class="relative aspect-[2/3] flex-1 overflow-hidden rounded-md border border-surface-800" style={`background: ${poster.tint}`}>
<span class="absolute inset-x-2 bottom-2 font-display text-[11px] font-bold leading-tight text-white [text-shadow:0_1px_8px_rgba(0,0,0,0.7)]">{poster.title}</span>
<span class="absolute inset-x-0 bottom-0 h-[3px] bg-white/15">
<span class="block h-full bg-accent-500" style={`width: ${poster.progress}%`} />
</span>
</div>
))
}
</div>
</a>
<!-- Downloads and recordings -->
<a href="/iptvnator/blog/v0-23-release-notes/" class={`${linkCellClass} lg:col-span-4`}>
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-50">Downloads and recordings</h3>
<p class="text-sm leading-relaxed text-surface-400">Keep movies offline, record live TV, watch later from the same library.</p>
<div class="mt-auto grid gap-2.5 pt-2 text-[13px]" aria-hidden="true">
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<span class="min-w-0">
<span class="block truncate text-surface-100">Signal Lost (2019)</span>
<span class="block font-mono text-[11px] text-surface-500">1.62 GB · 64%</span>
</span>
<span class="rounded-md border border-surface-700 px-2 py-1 font-mono text-[11px] text-surface-400">Paused</span>
<span class="col-span-2 block h-[3px] overflow-hidden rounded-full bg-surface-800">
<span class="block h-full w-[64%] bg-accent-500" />
</span>
</div>
<div class="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<span class="min-w-0">
<span class="block truncate text-surface-100">Aurora News</span>
<span class="block font-mono text-[11px] text-surface-500">Recording · 00:12:41</span>
</span>
<span class="flex items-center gap-1.5 font-mono text-[11px] text-red-400">
<span class="rec-dot h-1.5 w-1.5 rounded-full bg-red-500" />
REC
</span>
</div>
</div>
</a>
<!-- Remote control -->
<a href="/iptvnator/features/remote-control/" class={`${linkCellClass} lg:col-span-4`}>
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-50">A remote in your pocket</h3>
<p class="text-sm leading-relaxed text-surface-400">
The desktop app serves a web remote to any phone on your network: channels, volume, now playing.
</p>
<div class="mt-auto flex items-center gap-4 pt-2" aria-hidden="true">
<div class="grid grid-cols-3 gap-1">
{
['', '▲', '', '◀', 'OK', '▶', '', '▼', ''].map((key) => (
<span
class:list={[
'grid h-8 w-8 place-items-center rounded-md font-mono text-[10px]',
key ? 'border border-surface-700 bg-surface-900 text-surface-300' : '',
key === 'OK' && 'border-accent-500 text-accent-400',
]}
>
{key}
</span>
))
}
</div>
<div class="grid gap-1 font-mono text-[11px] text-surface-500">
<span>CH <span class="text-surface-200">+</span> / <span class="text-surface-200">−</span></span>
<span>VOL <span class="text-surface-200">+</span> / <span class="text-surface-200">−</span></span>
<span>Direct <span class="text-surface-200">12</span></span>
</div>
</div>
</a>
<!-- Runs everywhere / player of your choice -->
<div class={`${cellClass} sm:col-span-2 lg:col-span-12 lg:flex-row lg:items-center lg:gap-10`}>
<div class="lg:w-56 lg:shrink-0">
<h3 class="font-display text-lg font-semibold tracking-tight text-surface-50">Runs where you are</h3>
<p class="mt-1 text-sm text-surface-400">Same app, same library, every screen.</p>
</div>
<dl class="flex flex-wrap gap-x-7 gap-y-3 text-sm">
{
platforms.map((platform) => (
<div class="flex items-baseline gap-2">
<dt class="font-medium text-surface-100">{platform.name}</dt>
<dd class="text-surface-500">{platform.detail}</dd>
</div>
))
}
</dl>
<div class="flex flex-wrap items-center gap-2 lg:ml-auto">
<span class="mr-1 font-mono text-[11px] uppercase tracking-[0.12em] text-surface-500">Player</span>
{
players.map((player, i) => (
<span
class:list={[
'rounded-md border px-2.5 py-1.5 font-mono text-xs',
i === 0 ? 'border-accent-500 text-accent-400' : 'border-surface-700 text-surface-400',
]}
>
{player}
</span>
))
}
</div>
</div>
</div>
</div>
</section>
<script>
// Stagger the cells in as the grid scrolls into view.
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));
</script>
<style>
.rec-dot {
animation: rec-pulse 1.6s ease-in-out infinite;
}
@keyframes rec-pulse {
50% {
opacity: 0.35;
}
}
@media (prefers-reduced-motion: reduce) {
.rec-dot {
animation: none;
}
}
</style>