mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
* 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>
247 lines
11 KiB
Plaintext
247 lines
11 KiB
Plaintext
---
|
||
/**
|
||
* 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>
|