ipxreader.app: a home page for iPX (#216)
A static page in site/, built into site/dist by site/build.sh with the logo and icon from web/, for Cloudflare Pages. The hero's dial is the iPodderX logo's tuning scale at full size, its stations shows from the directory; the needle sweeps once on load (not with reduced motion) and lights the one it stops on. One typeface, Archivo, self-hosted, its width axis doing the work. No script; _headers sets a CSP that allows only the site's own files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@
|
||||
|
||||
.claude/settings.local.json
|
||||
__pycache__/
|
||||
site/dist/
|
||||
|
||||
6
site/_headers
Normal file
6
site/_headers
Normal file
@@ -0,0 +1,6 @@
|
||||
/*
|
||||
Content-Security-Policy: default-src 'none'; img-src 'self'; style-src 'self'; font-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'
|
||||
X-Content-Type-Options: nosniff
|
||||
Referrer-Policy: strict-origin-when-cross-origin
|
||||
/fonts/*
|
||||
Cache-Control: public, max-age=31536000, immutable
|
||||
10
site/build.sh
Executable file
10
site/build.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
# ipxreader.app: the page, its stylesheet and font, and the logo and icon from web/, into
|
||||
# site/dist, which Cloudflare Pages serves.
|
||||
set -e
|
||||
here=$(dirname "$0")
|
||||
rm -rf "$here/dist"
|
||||
mkdir -p "$here/dist/fonts"
|
||||
cp "$here/index.html" "$here/site.css" "$here/_headers" "$here/dist/"
|
||||
cp "$here/fonts/archivo.woff2" "$here/dist/fonts/"
|
||||
cp "$here/../web/logo.svg" "$here/../web/favicon.png" "$here/dist/"
|
||||
BIN
site/fonts/archivo.woff2
Normal file
BIN
site/fonts/archivo.woff2
Normal file
Binary file not shown.
103
site/index.html
Normal file
103
site/index.html
Normal file
@@ -0,0 +1,103 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>iPX, a podcatcher for the whole household</title>
|
||||
<meta name="description" content="iPX checks your feeds, downloads new episodes and gives everyone at home their own list, on your own server. The heir to iPodderX, free and open source.">
|
||||
<meta name="color-scheme" content="light">
|
||||
<link rel="icon" href="/favicon.png">
|
||||
<link rel="preload" href="/fonts/archivo.woff2" as="font" type="font/woff2" crossorigin>
|
||||
<link rel="stylesheet" href="/site.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="bar">
|
||||
<a class="home" href="/"><img src="/logo.svg" alt="" width="36" height="36">iPX</a>
|
||||
<nav aria-label="Elsewhere">
|
||||
<a href="https://directory.ipxreader.app">Directory</a>
|
||||
<a href="https://git.sdf1.net/rays/ipx">Source</a>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<h1>iPX</h1>
|
||||
<p class="lede">A podcatcher for the whole household, running on your own server.</p>
|
||||
<p class="intro">It checks your feeds, downloads new episodes, and gives everyone at home a list of their own to listen from in the browser, all sharing one copy of each file.</p>
|
||||
<p class="acts">
|
||||
<a class="btn primary" href="#run">Run it on your server</a>
|
||||
<a class="btn" href="https://directory.ipxreader.app">Browse the directory</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- The iPodderX logo is a radio's tuning scale and needle. Here the scale is full size and its
|
||||
stations are shows from the directory; the needle sweeps once and settles. -->
|
||||
<figure class="dial" aria-label="A radio dial tuned across shows in the iPX directory">
|
||||
<div class="scale"><div class="track">
|
||||
<ol class="freq" aria-hidden="true">
|
||||
<li>88</li><li>92</li><li>96</li><li>100</li><li>104</li><li>108</li>
|
||||
</ol>
|
||||
<ul class="stations">
|
||||
<li>99% Invisible</li>
|
||||
<li>Hackaday</li>
|
||||
<li>Clarkesworld</li>
|
||||
<li>Accidental Tech Podcast</li>
|
||||
<li>Daring Fireball</li>
|
||||
<li class="tuned">Ologies</li>
|
||||
<li>Oxide and Friends</li>
|
||||
<li>xkcd</li>
|
||||
<li>The Sporkful</li>
|
||||
<li>Acquired</li>
|
||||
<li>Construction Physics</li>
|
||||
<li>Dwarkesh Podcast</li>
|
||||
</ul>
|
||||
<span class="needle" aria-hidden="true"></span>
|
||||
</div></div>
|
||||
<figcaption>Some of what people listen to and read on iPX servers today.</figcaption>
|
||||
</figure>
|
||||
|
||||
<section class="does" aria-labelledby="does">
|
||||
<h2 id="does">What it does</h2>
|
||||
<dl>
|
||||
<div><dt>Everyone gets their own list</dt>
|
||||
<dd>Each person subscribes, reads and listens at their own pace, and picks up where they left off on any device. However many of you want an episode, there is one copy on disk.</dd></div>
|
||||
<div><dt>It does the fetching</dt>
|
||||
<dd>Feeds are checked on a schedule and new episodes arrive over HTTP or BitTorrent, filtered by keyword, media type or explicit content. Old files are cleared to stay within the disk space you give it, never one somebody pinned.</dd></div>
|
||||
<div><dt>DeadAir</dt>
|
||||
<dd>Turn it on for a show and iPX plays it without its long silences, leaving the pause you choose. The original file stays as it is.</dd></div>
|
||||
<div><dt>A shared directory</dt>
|
||||
<dd>iPX servers can share the public shows their people follow. Browse it to find something new, and subscribe in one click from your own server.</dd></div>
|
||||
<div><dt>On your phone</dt>
|
||||
<dd>Add it to your home screen and it opens like an app, with the same lists and the same place in each episode.</dd></div>
|
||||
<div><dt>Blogs too</dt>
|
||||
<dd>Any RSS or Atom feed works, and an OPML list can be a subscription of its own that keeps a whole folder of feeds in step.</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section class="run" id="run" aria-labelledby="run-h">
|
||||
<h2 id="run-h">Run it</h2>
|
||||
<p>You need a machine that stays on and Docker. These three steps start iPX with everything kept beside it in SQLite.</p>
|
||||
<ol>
|
||||
<li><p>Get the source.</p>
|
||||
<pre><code>git clone https://git.sdf1.net/rays/ipx.git
|
||||
cd ipx</code></pre></li>
|
||||
<li><p>Build iPX and its web page.</p>
|
||||
<pre><code>docker build -t ipx .
|
||||
docker build -f Dockerfile.web -t ipx-web .</code></pre></li>
|
||||
<li><p>Start them, then open port 8099 on that machine and sign in as <b>admin</b> with the password <b>ipodderx</b>. Change it in Settings straight away.</p>
|
||||
<pre><code>docker compose up -d</code></pre></li>
|
||||
</ol>
|
||||
<p>The <a href="https://git.sdf1.net/rays/ipx#run-it">README</a> covers Postgres, signing in through Cloudflare Access or Authentik, and every setting.</p>
|
||||
</section>
|
||||
|
||||
<section class="then" aria-labelledby="then">
|
||||
<h2 id="then">Since 2004</h2>
|
||||
<p>iPodderX was one of the first podcatchers, a Mac app by Ray Slakinski and August Trometer, from the year podcasts got their name. iPX is its engine rewritten in Rust for a server, with a page modelled on the original window: the feed list down the side, the episodes in a table, the player along the bottom.</p>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<p>iPX is free and open source under the MIT licence. <a href="https://git.sdf1.net/rays/ipx">Read the source</a> or <a href="https://git.sdf1.net/rays/ipx/issues">report a problem</a>.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
99
site/site.css
Normal file
99
site/site.css
Normal file
@@ -0,0 +1,99 @@
|
||||
/* ipxreader.app. The palette is the iPodderX logo's: the radio's navy body, the sky of its
|
||||
screen, the blue of its tuning scale, and the orange needle, which is the needle alone. One
|
||||
typeface, Archivo, its width doing the work: expanded for the name, condensed on the dial. */
|
||||
@font-face{font-family:Archivo;src:url(/fonts/archivo.woff2) format("woff2");font-weight:100 900;font-stretch:62% 125%;font-display:swap}
|
||||
:root{
|
||||
--navy:#0c2238; /* the radio */
|
||||
--ink:#16304a; /* body text, a step off the navy */
|
||||
--muted:#46607a;
|
||||
--sky:#dff0fb; /* its screen */
|
||||
--page:#f1f8fd;
|
||||
--scale:#2f6aa0; /* the tuning scale, and links */
|
||||
--tick:#8fc2ea; /* the scale on the dark dial */
|
||||
--needle:#ff5500;
|
||||
--rule:#c6d9ea;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html{-webkit-text-size-adjust:100%;scroll-behavior:smooth}
|
||||
body{margin:0;background:var(--page);color:var(--ink);font:400 1.0625rem/1.6 Archivo,system-ui,sans-serif;font-stretch:100%}
|
||||
a{color:var(--scale);text-underline-offset:.18em}
|
||||
a:focus-visible,.btn:focus-visible{outline:3px solid var(--scale);outline-offset:3px;border-radius:6px}
|
||||
main,.bar,footer{max-width:72rem;margin:0 auto;padding-inline:1.5rem}
|
||||
|
||||
.bar{display:flex;align-items:center;justify-content:space-between;padding-block:1.25rem}
|
||||
.home{display:flex;align-items:center;gap:.6rem;color:var(--navy);text-decoration:none;font-weight:800;font-stretch:112%;font-size:1.25rem}
|
||||
.home img{border-radius:9px;display:block}
|
||||
.bar nav{display:flex;gap:1.5rem}
|
||||
.bar nav a{color:var(--navy);text-decoration:none;font-weight:600}
|
||||
.bar nav a:hover{text-decoration:underline}
|
||||
|
||||
.hero{padding-block:3rem 2.5rem;max-width:46rem}
|
||||
h1{margin:0;color:var(--navy);font-weight:850;font-stretch:125%;font-size:clamp(6.5rem,24vw,15rem);line-height:.82;letter-spacing:-.045em}
|
||||
.lede{margin:1.75rem 0 0;color:var(--navy);font-weight:650;font-size:clamp(1.45rem,3.4vw,2rem);line-height:1.2;letter-spacing:-.01em}
|
||||
.intro{margin:1rem 0 0;max-width:38rem;color:var(--muted);font-size:1.125rem}
|
||||
.acts{display:flex;flex-wrap:wrap;gap:.75rem;margin:2rem 0 0}
|
||||
.btn{display:inline-block;padding:.8rem 1.35rem;border:2px solid var(--navy);border-radius:999px;color:var(--navy);text-decoration:none;font-weight:700}
|
||||
.btn:hover{background:var(--sky)}
|
||||
.btn.primary{background:var(--navy);color:#fff}
|
||||
.btn.primary:hover{background:#1a3a5c}
|
||||
|
||||
/* The dial: a frequency row, minor and major ticks, stations on two staggered rows as a printed
|
||||
dial has them, and the needle, which points down at the scale and lights the station it stops
|
||||
on rather than crossing its name. --at is where it stops: the tuned station's middle. */
|
||||
.dial{margin:1rem 0 0}
|
||||
.scale{background:var(--navy);border-radius:1.25rem;overflow:hidden}
|
||||
.track{position:relative;--at:45.8%}
|
||||
.freq{position:relative;list-style:none;margin:0;padding:1.1rem 4% 0;display:flex;justify-content:space-between;color:var(--tick);font-stretch:70%;font-weight:600;font-size:.95rem;font-variant-numeric:tabular-nums}
|
||||
.freq li{width:0;display:flex;justify-content:center} /* each number centred on its tick */
|
||||
.freq::after{content:"";position:absolute;left:4%;right:4%;top:2.6rem;height:1.6rem;
|
||||
background:
|
||||
repeating-linear-gradient(90deg,var(--tick) 0 2px,transparent 2px 20%) 0 0/100% 100%,
|
||||
repeating-linear-gradient(90deg,rgba(143,194,234,.55) 0 1px,transparent 1px 2%) 0 100%/100% 45%;
|
||||
background-repeat:no-repeat}
|
||||
.stations{list-style:none;margin:2.6rem 0 0;padding:0 1% 1.6rem;display:grid;grid-template-columns:repeat(12,minmax(0,1fr));align-items:start;
|
||||
color:#cfe3f4;font-stretch:65%;font-weight:550;font-size:.92rem;line-height:1.15;text-align:center}
|
||||
.stations li{padding-inline:.15rem}
|
||||
.stations li:nth-child(even){margin-top:1.7rem}
|
||||
.needle{position:absolute;top:2.35rem;height:2.7rem;left:var(--at);width:3px;margin-left:-1.5px;background:var(--needle);border-radius:2px;
|
||||
box-shadow:0 0 0 1px rgba(12,34,56,.6);animation:tune 2.4s cubic-bezier(.3,.7,.3,1) .4s both}
|
||||
.stations .tuned{text-decoration:underline 2px transparent;text-underline-offset:.3em;animation:lit .5s ease-out 2.6s both}
|
||||
@keyframes tune{0%{left:3%}68%{left:calc(var(--at) + 7%)}86%{left:calc(var(--at) - 1.4%)}100%{left:var(--at)}}
|
||||
@keyframes lit{from{color:#cfe3f4}to{color:#fff;font-weight:800;text-decoration-color:var(--needle)}}
|
||||
@media (prefers-reduced-motion:reduce){.needle,.stations .tuned{animation-duration:0s;animation-delay:0s}}
|
||||
figcaption{margin:.7rem 0 0;color:var(--muted);font-size:.9rem}
|
||||
|
||||
h2{margin:0 0 1.5rem;color:var(--navy);font-weight:800;font-stretch:112%;font-size:clamp(1.75rem,4vw,2.4rem);line-height:1.1;letter-spacing:-.02em}
|
||||
.does,.run,.then{padding-block:4rem 0}
|
||||
.does dl{margin:0;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:2.25rem 3.5rem}
|
||||
.does dt{color:var(--navy);font-weight:750;font-size:1.2rem;line-height:1.25}
|
||||
.does dd{margin:.45rem 0 0;max-width:34rem}
|
||||
|
||||
.run>p{max-width:40rem}
|
||||
.run ol{list-style:none;counter-reset:step;margin:2rem 0;padding:0;display:grid;gap:1.75rem;max-width:44rem}
|
||||
.run li{counter-increment:step;display:grid;grid-template-columns:3rem minmax(0,1fr);column-gap:1rem}
|
||||
.run li::before{content:counter(step);grid-row:span 2;color:var(--scale);font-weight:800;font-stretch:125%;font-size:2.4rem;line-height:1}
|
||||
.run li p{margin:0 0 .6rem}
|
||||
pre{margin:0;padding:1rem 1.15rem;background:var(--navy);color:var(--sky);border-radius:.75rem;overflow-x:auto;font:500 .92rem/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace}
|
||||
|
||||
.then p{max-width:40rem}
|
||||
footer{margin-top:5rem;padding-block:1.75rem 3rem;border-top:1px solid var(--rule);color:var(--muted);font-size:.95rem}
|
||||
footer p{margin:0}
|
||||
|
||||
@media (max-width:640px){
|
||||
main,.bar,footer{padding-inline:1rem}
|
||||
.bar nav{gap:1rem}
|
||||
.hero{padding-block:1.5rem 2rem}
|
||||
.does dl{grid-template-columns:minmax(0,1fr);gap:1.75rem}
|
||||
.run li{grid-template-columns:2.25rem minmax(0,1fr)}
|
||||
.run li::before{font-size:1.9rem}
|
||||
.dial{margin-inline:-1rem}
|
||||
.scale{border-radius:0}
|
||||
figcaption{padding-inline:1rem}
|
||||
/* Six stations, the tuned one third, rather than twelve too narrow to read. */
|
||||
.track{--at:41.7%}
|
||||
.stations{grid-template-columns:repeat(6,minmax(0,1fr));padding-inline:2%}
|
||||
.stations li:nth-child(-n+3),.stations li:nth-child(n+10){display:none}
|
||||
.stations li:nth-child(even){margin-top:0}
|
||||
.stations li:nth-child(odd){margin-top:1.7rem}
|
||||
pre{white-space:pre-wrap;overflow-wrap:anywhere}
|
||||
}
|
||||
Reference in New Issue
Block a user