refactor(admin): reuse dashboard Subscribe chips on feed detail page

Hoist the shared format chips, expiry pill, and copy icons into admin/ui.tsx
so the feed detail (emails) page renders the same Email + Subscribe block as
the dashboard list, dropping the old per-format rows, W3C validator images,
and the now-dead .feed-validate CSS.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-25 15:20:08 +02:00
parent 4e3d378850
commit 70552e5fa6
5 changed files with 249 additions and 268 deletions
+35
View File
@@ -1436,5 +1436,40 @@ describe("Admin Routes", () => {
expect(body).toContain("confirmation-banner");
expect(body).toContain("confirmation-dismiss");
});
it("feed emails page reuses the dashboard Subscribe chips design", async () => {
const authCookie = await loginAndGetCookie();
const repo = FeedRepository.from(mockEnv as unknown as Env);
const feedId = FeedId.generate();
const mailboxId = MailboxId.unchecked("subscribe.chips.07");
const feed = Feed.create(
feedId,
{
title: "Chips Detail Feed",
language: "en",
allowedSenders: [],
blockedSenders: [],
},
{ mailboxId },
);
await repo.save(feed);
const res = await request(`/admin/feeds/${feedId.value}/emails`, {
headers: { Cookie: authCookie },
});
expect(res.status).toBe(200);
const body = await res.text();
// The Subscribe chips block surfaces all three formats with copy/open/validate.
expect(body).toContain("feed-formats-chips");
expect(body).toContain(`/rss/${feedId.value}`);
expect(body).toContain(`/atom/${feedId.value}`);
expect(body).toContain(`/json/${feedId.value}`);
expect(body).toContain(`${mailboxId.value}@test.getmynews.app`);
// The old W3C validator-image block is gone.
expect(body).not.toContain("validator.w3.org/feed/images");
});
});
});
+9 -187
View File
@@ -7,16 +7,18 @@ import { csrf } from "hono/csrf";
import { ADMIN_COOKIE_MAX_AGE } from "../config/constants";
import { logger } from "../infrastructure/logger";
import { timingSafeEqual, checkProxyAuth } from "../infrastructure/auth";
import { Layout, clampText } from "./admin/ui";
import {
Layout,
clampText,
CopyIcon,
CheckIcon,
FeedFormats,
ExpiryBadge,
} from "./admin/ui";
import { FeedRepository } from "../infrastructure/feed-repository";
import { FeedId } from "../domain/value-objects/feed-id";
import { editFeedDetails } from "../application/feed-service";
import {
feedEmailAddress,
feedFormatUrl,
feedValidatorUrl,
type FeedFormat,
} from "../infrastructure/urls";
import { feedEmailAddress } from "../infrastructure/urls";
import { feedsRouter } from "./admin/feeds";
import { emailsRouter } from "./admin/emails";
import { handleOpml } from "./opml";
@@ -202,41 +204,6 @@ app.get("/logout", (c) => {
// dashboardScript is compiled from src/scripts/client/dashboard.ts via `npm run build:client`.
// It is imported from src/scripts/generated/dashboard.ts above.
// ── Shared SVG icons ──────────────────────────────────────────────────────────
const CopyIcon = () => (
<svg
class="copy-icon copy-icon-original"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
);
const CheckIcon = () => (
<svg
class="copy-icon copy-icon-success"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6L9 17l-5-5"></path>
</svg>
);
type CopyFieldInlineProps = {
value: string;
emailAddress?: string;
@@ -256,151 +223,6 @@ 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) {
const h = Math.floor(-remaining / 3_600_000);
return {
label: h > 0 ? `Expired ${h}h ago` : "Just expired",
expired: true,
};
}
const h = Math.floor(remaining / 3_600_000);
if (h >= 48) {
return { label: `Expires in ${Math.floor(h / 24)}d`, expired: false };
}
const m = Math.floor((remaining % 3_600_000) / 60_000);
return {
label: h > 0 ? `Expires in ${h}h ${m}m` : `Expires in ${m}m`,
expired: false,
};
}
const ExpiryBadge = ({ expiresAt }: { expiresAt: number }) => {
const { label, expired } = formatExpiry(expiresAt);
return (
<span class={`pill ${expired ? "pill-expired" : "pill-expiry"}`}>
{label}
</span>
);
};
const ConfirmationPill = ({ feedId }: { feedId: string }) => (
<a class="pill pill-confirmation" href={`/admin/feeds/${feedId}/emails`}>
Confirmation pending
+15 -70
View File
@@ -1,7 +1,14 @@
import { Hono } from "hono";
import { Env, EmailMetadata } from "../../types";
import { logger } from "../../infrastructure/logger";
import { Layout, clampText } from "./ui";
import {
Layout,
clampText,
CopyIcon,
CheckIcon,
FeedFormats,
ExpiryBadge,
} from "./ui";
import {
deleteAttachmentsForEmails,
deleteKeysWithConcurrency,
@@ -9,8 +16,6 @@ import {
import { FeedRepository } from "../../infrastructure/feed-repository";
import { FeedId } from "../../domain/value-objects/feed-id";
import {
feedRssUrl,
feedAtomUrl,
feedEmailAddress,
baseUrl,
entryPath,
@@ -25,41 +30,6 @@ type AppEnv = { Bindings: Env };
export const emailsRouter = new Hono<AppEnv>();
// ── Shared SVG icons ──────────────────────────────────────────────────────────
const CopyIcon = () => (
<svg
class="copy-icon copy-icon-original"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
);
const CheckIcon = () => (
<svg
class="copy-icon copy-icon-success"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6L9 17l-5-5"></path>
</svg>
);
type CopyFieldProps = {
label: string;
value: string;
@@ -171,8 +141,6 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
}
const emailAddress = feedEmailAddress(feedConfig.mailbox_id, env);
const rssUrl = feedRssUrl(feedId, env);
const atomUrl = feedAtomUrl(feedId, env);
return c.html(
<Layout title={`${feedConfig.title} - Emails`}>
@@ -189,36 +157,13 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
</div>
<div class="card">
<h2>Feed Details</h2>
<div>
<CopyField label="Email Address:" value={emailAddress} />
<CopyField label="RSS Feed:" value={rssUrl} />
<CopyField label="Atom Feed:" value={atomUrl} />
</div>
<div class="feed-validate">
<a
href={`https://validator.w3.org/feed/check.cgi?url=${encodeURIComponent(atomUrl)}`}
target="_blank"
rel="noopener noreferrer"
>
<img
src="https://validator.w3.org/feed/images/valid-atom.png"
alt="[Valid Atom 1.0]"
title="Validate my Atom 1.0 feed"
/>
</a>
<a
href={`https://validator.w3.org/feed/check.cgi?url=${encodeURIComponent(rssUrl)}`}
target="_blank"
rel="noopener noreferrer"
>
<img
src="https://validator.w3.org/feed/images/valid-rss-rogers.png"
alt="[Valid RSS]"
title="Validate my RSS feed"
/>
</a>
</div>
{feedConfig.expires_at && (
<div class="feed-header">
<ExpiryBadge expiresAt={feedConfig.expires_at} />
</div>
)}
<CopyField label="Email:" value={emailAddress} />
<FeedFormats feedId={feedId} env={env} />
</div>
{feedMetadata.pendingConfirmation && (
+190
View File
@@ -4,6 +4,12 @@ import componentsCss from "../../styles/components.css";
import utilitiesCss from "../../styles/utilities.css";
import { interactiveScripts } from "../../scripts/index";
import { FAVICON_PATH } from "../favicon";
import { Env } from "../../types";
import {
feedFormatUrl,
feedValidatorUrl,
type FeedFormat,
} from "../../infrastructure/urls";
const designSystem = [
variablesCss,
@@ -89,3 +95,187 @@ export function clampText(value: string, maxLen: number): string {
}
return `${raw.slice(0, maxLen - 3).trimEnd()}...`;
}
// ── Shared SVG icons ──────────────────────────────────────────────────────────
export const CopyIcon = () => (
<svg
class="copy-icon copy-icon-original"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
</svg>
);
export const CheckIcon = () => (
<svg
class="copy-icon copy-icon-success"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 6L9 17l-5-5"></path>
</svg>
);
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>
);
// ── Feed format chips ("Subscribe" block) ─────────────────────────────────────
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>
);
};
export 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>
);
// ── Expiry pill ───────────────────────────────────────────────────────────────
function formatExpiry(expiresAt: number): { label: string; expired: boolean } {
const remaining = expiresAt - Date.now();
if (remaining <= 0) {
const h = Math.floor(-remaining / 3_600_000);
return {
label: h > 0 ? `Expired ${h}h ago` : "Just expired",
expired: true,
};
}
const h = Math.floor(remaining / 3_600_000);
if (h >= 48) {
return { label: `Expires in ${Math.floor(h / 24)}d`, expired: false };
}
const m = Math.floor((remaining % 3_600_000) / 60_000);
return {
label: h > 0 ? `Expires in ${h}h ${m}m` : `Expires in ${m}m`,
expired: false,
};
}
export const ExpiryBadge = ({ expiresAt }: { expiresAt: number }) => {
const { label, expired } = formatExpiry(expiresAt);
return (
<span class={`pill ${expired ? "pill-expired" : "pill-expiry"}`}>
{label}
</span>
);
};
-11
View File
@@ -1175,17 +1175,6 @@ table.table code {
border-color: rgba(255, 69, 58, 0.35);
}
/* Validation badges */
.feed-validate {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.feed-validate img {
display: block;
}
/* Feed and Email Lists */
.feed-list,
.email-list {