| 1 | // Builder UI for the root page. All persistent state lives in the URL — |
| 2 | // the pending list here only exists until the user presses Go. |
| 3 | |
| 4 | // Example feeds. Favicon is optional; when omitted it falls back to the |
| 5 | // site's /favicon.ico. |
| 6 | const EXAMPLE_FEEDS = [ |
| 7 | { |
| 8 | title: "Kagi News", |
| 9 | url: "https://news.kagi.com/world.xml", |
| 10 | favicon: "https://news.kagi.com/favicon.svg", |
| 11 | }, |
| 12 | { |
| 13 | title: "NPR", |
| 14 | url: "https://feeds.npr.org/1002/rss.xml", |
| 15 | favicon: "https://media.npr.org/chrome/favicon/favicon-180x180.png" |
| 16 | }, |
| 17 | { |
| 18 | title: "Quanta", |
| 19 | url: "https://www.quantamagazine.org/feed/", |
| 20 | favicon: "https://www.quantamagazine.org/wp-content/themes/quanta2024/frontend/images/favicon.png" |
| 21 | }, |
| 22 | { |
| 23 | title: "Hacker News", |
| 24 | url: "https://news.ycombinator.com/rss", |
| 25 | favicon: "https://news.ycombinator.com/y18.svg", |
| 26 | }, |
| 27 | { |
| 28 | title: "NASA Images", |
| 29 | url: "https://www.nasa.gov/feeds/iotd-feed/", |
| 30 | favicon: "https://www.nasa.gov/wp-content/plugins/nasa-hds-core-setup/assets/favicons/apple-touch-icon-57x57.png" |
| 31 | }, |
| 32 | { |
| 33 | title: "Colossal", |
| 34 | url: "https://www.thisiscolossal.com/feed/", |
| 35 | favicon: "https://www.thisiscolossal.com/wp-content/uploads/2024/08/icon-crow-150x150.png" |
| 36 | }, |
| 37 | { |
| 38 | title: "Bubbles", |
| 39 | url: "https://bubbles.town/feed", |
| 40 | favicon: "https://bubbles.town/static/favicon-32.png", |
| 41 | }, |
| 42 | { |
| 43 | title: "Robert Birming", |
| 44 | url: "https://robertbirming.com/feed/", |
| 45 | favicon: "data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20100%20100'%3E%3Ctext%20y='.9em'%20font-size='90'%3E:-)%3C/text%3E%3C/svg%3E" |
| 46 | }, |
| 47 | { |
| 48 | title: "Art Calendar", |
| 49 | url: "https://easel.stevedylan.dev/feed.xml", |
| 50 | favicon: "https://easel.stevedylan.dev/static/favicon.ico" |
| 51 | } |
| 52 | ]; |
| 53 | |
| 54 | function fallbackFavicon(feedURL) { |
| 55 | try { |
| 56 | return new URL(feedURL).origin + "/favicon.ico"; |
| 57 | } catch { |
| 58 | return ""; |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | function faviconImg(src) { |
| 63 | const img = document.createElement("img"); |
| 64 | img.className = "favicon"; |
| 65 | img.width = 16; |
| 66 | img.height = 16; |
| 67 | img.alt = ""; |
| 68 | img.src = src; |
| 69 | img.addEventListener("error", () => img.remove()); |
| 70 | return img; |
| 71 | } |
| 72 | |
| 73 | const input = document.getElementById("feed-input"); |
| 74 | const addButton = document.getElementById("add-button"); |
| 75 | const goButton = document.getElementById("go-button"); |
| 76 | const status = document.getElementById("status"); |
| 77 | const examples = document.getElementById("examples"); |
| 78 | const pendingList = document.getElementById("pending"); |
| 79 | |
| 80 | const pending = []; |
| 81 | |
| 82 | function setStatus(text, spinning) { |
| 83 | status.textContent = text; |
| 84 | status.classList.toggle("hidden", !text && !spinning); |
| 85 | status.classList.toggle("spinner", Boolean(spinning)); |
| 86 | } |
| 87 | |
| 88 | function renderPending() { |
| 89 | pendingList.innerHTML = ""; |
| 90 | for (const feed of pending) { |
| 91 | const li = document.createElement("li"); |
| 92 | const favicon = feed.favicon || fallbackFavicon(feed.url); |
| 93 | if (favicon) li.appendChild(faviconImg(favicon)); |
| 94 | const label = document.createElement("span"); |
| 95 | label.className = "pending-label"; |
| 96 | label.textContent = feed.title || feed.url; |
| 97 | const url = document.createElement("span"); |
| 98 | url.className = "pending-url"; |
| 99 | url.textContent = feed.url; |
| 100 | const remove = document.createElement("button"); |
| 101 | remove.className = "link-button danger"; |
| 102 | remove.textContent = "×"; |
| 103 | remove.title = "Remove feed"; |
| 104 | remove.addEventListener("click", () => { |
| 105 | pending.splice(pending.indexOf(feed), 1); |
| 106 | renderPending(); |
| 107 | }); |
| 108 | li.append(label, url, remove); |
| 109 | pendingList.appendChild(li); |
| 110 | } |
| 111 | goButton.disabled = pending.length === 0; |
| 112 | } |
| 113 | |
| 114 | function addFeed(url, title, favicon) { |
| 115 | if (pending.some((f) => f.url === url)) { |
| 116 | setStatus("already added", false); |
| 117 | return; |
| 118 | } |
| 119 | pending.push({ url, title, favicon }); |
| 120 | setStatus("", false); |
| 121 | renderPending(); |
| 122 | } |
| 123 | |
| 124 | async function resolve() { |
| 125 | const value = input.value.trim(); |
| 126 | if (!value) return; |
| 127 | input.disabled = true; |
| 128 | addButton.disabled = true; |
| 129 | setStatus("finding feed", true); |
| 130 | try { |
| 131 | const resp = await fetch("/api/resolve?url=" + encodeURIComponent(value)); |
| 132 | const body = await resp.json(); |
| 133 | if (!resp.ok) { |
| 134 | setStatus(body.error || "could not resolve feed", false); |
| 135 | return; |
| 136 | } |
| 137 | for (const feed of body.feeds) { |
| 138 | addFeed(feed.url, feed.title, feed.favicon); |
| 139 | } |
| 140 | input.value = ""; |
| 141 | } catch { |
| 142 | setStatus("could not resolve feed", false); |
| 143 | } finally { |
| 144 | input.disabled = false; |
| 145 | addButton.disabled = false; |
| 146 | input.focus(); |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | addButton.addEventListener("click", resolve); |
| 151 | input.addEventListener("keydown", (e) => { |
| 152 | if (e.key === "Enter") { |
| 153 | e.preventDefault(); |
| 154 | resolve(); |
| 155 | } |
| 156 | }); |
| 157 | |
| 158 | goButton.addEventListener("click", () => { |
| 159 | if (pending.length === 0) return; |
| 160 | location.href = "/?url=" + pending.map((f) => encodeURIComponent(f.url)).join(","); |
| 161 | }); |
| 162 | |
| 163 | for (const feed of EXAMPLE_FEEDS) { |
| 164 | const chip = document.createElement("button"); |
| 165 | chip.className = "chip"; |
| 166 | const favicon = feed.favicon || fallbackFavicon(feed.url); |
| 167 | if (favicon) chip.appendChild(faviconImg(favicon)); |
| 168 | chip.appendChild(document.createTextNode(feed.title)); |
| 169 | chip.title = feed.url; |
| 170 | chip.addEventListener("click", () => addFeed(feed.url, feed.title, feed.favicon)); |
| 171 | examples.appendChild(chip); |
| 172 | } |