static/app.js 5.1 K raw
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
}