mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(admin): per-feed Subscribe chips with copy/open/validate for RSS/Atom/JSON
Replace the stacked RSS/Atom URL rows in the dashboard with a compact "Subscribe" chip block exposing all three feed formats — including JSON Feed, previously absent from the admin UI. Each chip carries copy, open, and validate actions; validation links to the W3C Feed Validator (RSS/Atom) and validator.jsonfeed.org (JSON). The Table view's RSS+Atom columns fold into a single Formats column. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,7 @@ kill-the-news keeps the same workflow while avoiding shared domains and shared d
|
||||
- Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow)
|
||||
- Inline double-confirm delete interactions with toast feedback in the admin dashboard
|
||||
- Resizable + sortable table columns in the admin dashboard (Table view)
|
||||
- Per-feed "Subscribe" chips in the admin dashboard — copy, open, or validate the feed in one click for each of RSS, Atom, and JSON Feed (validation via the W3C Feed Validator and validator.jsonfeed.org)
|
||||
- Unique newsletter addresses per feed (for example `apple.mountain.42@yourdomain.com`)
|
||||
- **Separate inbound address and feed URL** — the address you subscribe with (`apple.mountain.42@yourdomain.com`) and the public feed URL (`/rss/<opaque-id>`) use **independent** ids, so you can share a feed without leaking the address that feeds it, and an address harvested by a newsletter can't be used to read your feed (`/rss/<your-address>` 404s)
|
||||
- Cloudflare Email Workers ingestion (no third-party service)
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
feedRssUrl,
|
||||
feedAtomUrl,
|
||||
feedJsonUrl,
|
||||
feedFormatUrl,
|
||||
feedValidatorUrl,
|
||||
} from "./urls";
|
||||
import { Env } from "../types";
|
||||
|
||||
const env = { DOMAIN: "getmynews.app" } as Env;
|
||||
const feedId = "gAf6wiKyanpppcKX9o3B_Q";
|
||||
|
||||
describe("feed URL builders", () => {
|
||||
it("builds RSS/Atom/JSON feed URLs", () => {
|
||||
expect(feedRssUrl(feedId, env)).toBe(
|
||||
"https://getmynews.app/rss/gAf6wiKyanpppcKX9o3B_Q",
|
||||
);
|
||||
expect(feedAtomUrl(feedId, env)).toBe(
|
||||
"https://getmynews.app/atom/gAf6wiKyanpppcKX9o3B_Q",
|
||||
);
|
||||
expect(feedJsonUrl(feedId, env)).toBe(
|
||||
"https://getmynews.app/json/gAf6wiKyanpppcKX9o3B_Q",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a format to its feed URL", () => {
|
||||
expect(feedFormatUrl("rss", feedId, env)).toBe(feedRssUrl(feedId, env));
|
||||
expect(feedFormatUrl("atom", feedId, env)).toBe(feedAtomUrl(feedId, env));
|
||||
expect(feedFormatUrl("json", feedId, env)).toBe(feedJsonUrl(feedId, env));
|
||||
});
|
||||
});
|
||||
|
||||
describe("feedValidatorUrl", () => {
|
||||
it("points JSON feeds at validator.jsonfeed.org with the encoded feed URL", () => {
|
||||
expect(feedValidatorUrl("json", feedId, env)).toBe(
|
||||
"https://validator.jsonfeed.org/?url=" +
|
||||
encodeURIComponent(feedJsonUrl(feedId, env)),
|
||||
);
|
||||
});
|
||||
|
||||
it("points RSS and Atom feeds at the W3C feed validator", () => {
|
||||
expect(feedValidatorUrl("rss", feedId, env)).toBe(
|
||||
"https://validator.w3.org/feed/check.cgi?url=" +
|
||||
encodeURIComponent(feedRssUrl(feedId, env)),
|
||||
);
|
||||
expect(feedValidatorUrl("atom", feedId, env)).toBe(
|
||||
"https://validator.w3.org/feed/check.cgi?url=" +
|
||||
encodeURIComponent(feedAtomUrl(feedId, env)),
|
||||
);
|
||||
});
|
||||
|
||||
it("percent-encodes the feed URL so the validator query is well-formed", () => {
|
||||
const url = feedValidatorUrl("json", feedId, env);
|
||||
expect(url).toContain("https%3A%2F%2Fgetmynews.app%2Fjson%2F");
|
||||
expect(url).not.toContain("?url=https://");
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,10 @@ export function feedAtomUrl(feedId: string, env: Env): string {
|
||||
return `${baseUrl(env)}/atom/${feedId}`;
|
||||
}
|
||||
|
||||
export function feedJsonUrl(feedId: string, env: Env): string {
|
||||
return `${baseUrl(env)}/json/${feedId}`;
|
||||
}
|
||||
|
||||
export function feedUrl(
|
||||
format: "rss" | "atom",
|
||||
feedId: string,
|
||||
@@ -21,6 +25,34 @@ export function feedUrl(
|
||||
return format === "rss" ? feedRssUrl(feedId, env) : feedAtomUrl(feedId, env);
|
||||
}
|
||||
|
||||
export type FeedFormat = "rss" | "atom" | "json";
|
||||
|
||||
export function feedFormatUrl(
|
||||
format: FeedFormat,
|
||||
feedId: string,
|
||||
env: Env,
|
||||
): string {
|
||||
if (format === "atom") return feedAtomUrl(feedId, env);
|
||||
if (format === "json") return feedJsonUrl(feedId, env);
|
||||
return feedRssUrl(feedId, env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Link to a third-party validator for the public feed URL: the W3C Feed
|
||||
* Validator for RSS/Atom, validator.jsonfeed.org for JSON Feed. Used in the
|
||||
* admin UI so an operator can confirm a feed parses in real readers.
|
||||
*/
|
||||
export function feedValidatorUrl(
|
||||
format: FeedFormat,
|
||||
feedId: string,
|
||||
env: Env,
|
||||
): string {
|
||||
const encoded = encodeURIComponent(feedFormatUrl(format, feedId, env));
|
||||
return format === "json"
|
||||
? `https://validator.jsonfeed.org/?url=${encoded}`
|
||||
: `https://validator.w3.org/feed/check.cgi?url=${encoded}`;
|
||||
}
|
||||
|
||||
export function feedEmailAddress(mailboxId: string, env: Env): string {
|
||||
// The mailbox→address shape lives on the VO; this edge only resolves the domain.
|
||||
return MailboxId.unchecked(mailboxId).emailAddress(
|
||||
|
||||
@@ -189,6 +189,13 @@ describe("Admin Routes", () => {
|
||||
expect(html).toContain(`${mailboxId}@test.getmynews.app`);
|
||||
expect(html).toContain(`/rss/${feedId}`);
|
||||
expect(html).not.toContain(`/rss/${mailboxId}`);
|
||||
|
||||
// The feed-formats block surfaces all three formats (incl. JSON Feed)
|
||||
// plus per-format validator links.
|
||||
expect(html).toContain(`/atom/${feedId}`);
|
||||
expect(html).toContain(`/json/${feedId}`);
|
||||
expect(html).toContain("validator.jsonfeed.org");
|
||||
expect(html).toContain("validator.w3.org/feed");
|
||||
});
|
||||
|
||||
it("should reject feed creation with missing title", async () => {
|
||||
|
||||
+125
-87
@@ -12,9 +12,10 @@ import { FeedRepository } from "../infrastructure/feed-repository";
|
||||
import { FeedId } from "../domain/value-objects/feed-id";
|
||||
import { editFeedDetails } from "../application/feed-service";
|
||||
import {
|
||||
feedRssUrl,
|
||||
feedAtomUrl,
|
||||
feedEmailAddress,
|
||||
feedFormatUrl,
|
||||
feedValidatorUrl,
|
||||
type FeedFormat,
|
||||
} from "../infrastructure/urls";
|
||||
import { feedsRouter } from "./admin/feeds";
|
||||
import { emailsRouter } from "./admin/emails";
|
||||
@@ -255,6 +256,122 @@ const CopyFieldInline = ({ value }: CopyFieldInlineProps) => (
|
||||
</div>
|
||||
);
|
||||
|
||||
const OpenIcon = () => (
|
||||
<svg
|
||||
class="chip-icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
|
||||
<polyline points="15 3 21 3 21 9"></polyline>
|
||||
<line x1="10" y1="14" x2="21" y2="3"></line>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ValidateIcon = () => (
|
||||
<svg
|
||||
class="chip-icon"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const FORMAT_LABELS: Record<FeedFormat, string> = {
|
||||
rss: "RSS",
|
||||
atom: "Atom",
|
||||
json: "JSON",
|
||||
};
|
||||
|
||||
const FormatChip = ({
|
||||
format,
|
||||
feedId,
|
||||
env,
|
||||
}: {
|
||||
format: FeedFormat;
|
||||
feedId: string;
|
||||
env: Env;
|
||||
}) => {
|
||||
const url = feedFormatUrl(format, feedId, env);
|
||||
const validateUrl = feedValidatorUrl(format, feedId, env);
|
||||
const label = FORMAT_LABELS[format];
|
||||
return (
|
||||
<div class="format-chip" data-format={format}>
|
||||
<span class="format-chip-label">{label}</span>
|
||||
<span class="format-chip-actions">
|
||||
<span class="copyable copyable-chip">
|
||||
<span
|
||||
class="copyable-content"
|
||||
title={`Copy ${label} feed URL`}
|
||||
aria-label={`Copy ${label} feed URL`}
|
||||
>
|
||||
<span class="copyable-value" data-copy={url} hidden></span>
|
||||
<span class="copy-icon-container">
|
||||
<CopyIcon />
|
||||
<CheckIcon />
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<a
|
||||
class="chip-action"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Open ${label} feed in a new tab`}
|
||||
aria-label={`Open ${label} feed in a new tab`}
|
||||
>
|
||||
<OpenIcon />
|
||||
</a>
|
||||
<a
|
||||
class="chip-action"
|
||||
href={validateUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
title={`Validate ${label} feed`}
|
||||
aria-label={`Validate ${label} feed`}
|
||||
>
|
||||
<ValidateIcon />
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const FeedFormats = ({
|
||||
feedId,
|
||||
env,
|
||||
compact,
|
||||
}: {
|
||||
feedId: string;
|
||||
env: Env;
|
||||
compact?: boolean;
|
||||
}) => (
|
||||
<div class={`feed-formats${compact ? " feed-formats-compact" : ""}`}>
|
||||
{!compact && <span class="feed-formats-label">Subscribe</span>}
|
||||
<div class="feed-formats-chips">
|
||||
<FormatChip format="rss" feedId={feedId} env={env} />
|
||||
<FormatChip format="atom" feedId={feedId} env={env} />
|
||||
<FormatChip format="json" feedId={feedId} env={env} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
function formatExpiry(expiresAt: number): { label: string; expired: boolean } {
|
||||
const remaining = expiresAt - Date.now();
|
||||
if (remaining <= 0) {
|
||||
@@ -522,8 +639,7 @@ app.get("/", async (c) => {
|
||||
<col data-col="title" style="width: 280px;" />
|
||||
<col data-col="feedId" style="width: 150px;" />
|
||||
<col data-col="email" style="width: 200px;" />
|
||||
<col data-col="rss" style="width: 190px;" />
|
||||
<col data-col="atom" style="width: 190px;" />
|
||||
<col data-col="formats" style="width: 230px;" />
|
||||
<col data-col="expires" style="width: 130px;" />
|
||||
<col data-col="actions" style="width: 170px;" />
|
||||
</colgroup>
|
||||
@@ -602,47 +718,11 @@ app.get("/", async (c) => {
|
||||
title="Resize"
|
||||
></div>
|
||||
</th>
|
||||
<th
|
||||
class="th-resizable"
|
||||
data-sort-key="rss"
|
||||
aria-sort="none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="th-button"
|
||||
data-sort-key="rss"
|
||||
>
|
||||
RSS
|
||||
<span
|
||||
class="sort-indicator"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
<th class="th-resizable">
|
||||
<span>Formats</span>
|
||||
<div
|
||||
class="col-resizer"
|
||||
data-col="rss"
|
||||
title="Resize"
|
||||
></div>
|
||||
</th>
|
||||
<th
|
||||
class="th-resizable"
|
||||
data-sort-key="atom"
|
||||
aria-sort="none"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="th-button"
|
||||
data-sort-key="atom"
|
||||
>
|
||||
Atom
|
||||
<span
|
||||
class="sort-indicator"
|
||||
aria-hidden="true"
|
||||
></span>
|
||||
</button>
|
||||
<div
|
||||
class="col-resizer"
|
||||
data-col="atom"
|
||||
data-col="formats"
|
||||
title="Resize"
|
||||
></div>
|
||||
</th>
|
||||
@@ -670,15 +750,11 @@ app.get("/", async (c) => {
|
||||
feed.mailbox_id,
|
||||
env,
|
||||
);
|
||||
const rssUrl = feedRssUrl(feed.id, env);
|
||||
const atomUrl = feedAtomUrl(feed.id, env);
|
||||
const titleDisplay = clampText(feed.title, 160);
|
||||
const titleHover = clampText(feed.title, 1000);
|
||||
const sortTitle = titleHover.toLowerCase();
|
||||
const sortFeedId = feed.id.toLowerCase();
|
||||
const sortEmail = emailAddress.toLowerCase();
|
||||
const sortRss = rssUrl.toLowerCase();
|
||||
const sortAtom = atomUrl.toLowerCase();
|
||||
const descDisplay = clampText(
|
||||
feed.description || "",
|
||||
220,
|
||||
@@ -698,8 +774,6 @@ app.get("/", async (c) => {
|
||||
data-sort-title={sortTitle}
|
||||
data-sort-feed-id={sortFeedId}
|
||||
data-sort-email={sortEmail}
|
||||
data-sort-rss={sortRss}
|
||||
data-sort-atom={sortAtom}
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
@@ -743,10 +817,7 @@ app.get("/", async (c) => {
|
||||
<CopyFieldInline value={emailAddress} />
|
||||
</td>
|
||||
<td>
|
||||
<CopyFieldInline value={rssUrl} />
|
||||
</td>
|
||||
<td>
|
||||
<CopyFieldInline value={atomUrl} />
|
||||
<FeedFormats feedId={feed.id} env={env} compact />
|
||||
</td>
|
||||
<td>
|
||||
{feed.expires_at ? (
|
||||
@@ -827,8 +898,6 @@ app.get("/", async (c) => {
|
||||
<ul class="feed-list">
|
||||
{feedsWithConfig.map((feed) => {
|
||||
const emailAddress = feedEmailAddress(feed.mailbox_id, env);
|
||||
const rssUrl = feedRssUrl(feed.id, env);
|
||||
const atomUrl = feedAtomUrl(feed.id, env);
|
||||
const titleDisplay = clampText(feed.title, 140);
|
||||
const titleHover = clampText(feed.title, 1000);
|
||||
const descDisplay = clampText(feed.description || "", 240);
|
||||
@@ -884,38 +953,7 @@ app.get("/", async (c) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copyable">
|
||||
<span class="copyable-label">RSS Feed:</span>
|
||||
<div class="copyable-content">
|
||||
<span
|
||||
class="copyable-value"
|
||||
data-copy={rssUrl}
|
||||
title={rssUrl}
|
||||
>
|
||||
{rssUrl}
|
||||
</span>
|
||||
<div class="copy-icon-container">
|
||||
<CopyIcon />
|
||||
<CheckIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="copyable">
|
||||
<span class="copyable-label">Atom Feed:</span>
|
||||
<div class="copyable-content">
|
||||
<span
|
||||
class="copyable-value"
|
||||
data-copy={atomUrl}
|
||||
title={atomUrl}
|
||||
>
|
||||
{atomUrl}
|
||||
</span>
|
||||
<div class="copy-icon-container">
|
||||
<CopyIcon />
|
||||
<CheckIcon />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<FeedFormats feedId={feed.id} env={env} />
|
||||
</div>
|
||||
|
||||
<div class="feed-buttons">
|
||||
|
||||
@@ -139,14 +139,14 @@ function setupFeedTableResizing(): void {
|
||||
title: 220,
|
||||
feedId: 120,
|
||||
email: 160,
|
||||
rss: 160,
|
||||
formats: 200,
|
||||
actions: 160,
|
||||
};
|
||||
const defaultWidths: Record<string, number> = {
|
||||
title: 340,
|
||||
feedId: 160,
|
||||
email: 220,
|
||||
rss: 220,
|
||||
formats: 230,
|
||||
actions: 200,
|
||||
};
|
||||
|
||||
|
||||
@@ -887,6 +887,103 @@ table.table code {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* ── Feed format chips ("Subscribe" block) ── */
|
||||
.feed-formats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.feed-formats-label {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.feed-formats-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.format-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
padding: 4px 6px 4px 10px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background-color: rgba(60, 60, 67, 0.06);
|
||||
transition:
|
||||
border-color var(--transition-fast),
|
||||
background-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.format-chip:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.format-chip-label {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
font-size: var(--font-size-sm);
|
||||
letter-spacing: 0.02em;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.format-chip-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.chip-action,
|
||||
.copyable.copyable-chip .copyable-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color var(--transition-fast),
|
||||
color var(--transition-fast);
|
||||
}
|
||||
|
||||
.chip-action:hover,
|
||||
.copyable.copyable-chip .copyable-content:hover {
|
||||
background-color: rgba(60, 60, 67, 0.18);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* Strip the boxed wrapper styling so only the icon button shows in a chip */
|
||||
.copyable.copyable-chip {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.copyable.copyable-chip .copyable-content {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.chip-icon {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* Compact variant for the feeds table cell */
|
||||
.feed-formats-compact .feed-formats-chips {
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.feed-formats-compact .format-chip {
|
||||
padding: 2px 4px 2px 8px;
|
||||
}
|
||||
|
||||
.button-delete {
|
||||
transition:
|
||||
background-color 180ms ease,
|
||||
|
||||
Reference in New Issue
Block a user