From 70552e5fa696a1cafe4101143ee120a6cce360aa Mon Sep 17 00:00:00 2001 From: Julien Herr Date: Mon, 25 May 2026 15:20:08 +0200 Subject: [PATCH] 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 --- src/routes/admin.test.ts | 35 +++++++ src/routes/admin.tsx | 196 ++---------------------------------- src/routes/admin/emails.tsx | 85 +++------------- src/routes/admin/ui.tsx | 190 ++++++++++++++++++++++++++++++++++ src/styles/components.css | 11 -- 5 files changed, 249 insertions(+), 268 deletions(-) diff --git a/src/routes/admin.test.ts b/src/routes/admin.test.ts index 2f3164f..a765934 100644 --- a/src/routes/admin.test.ts +++ b/src/routes/admin.test.ts @@ -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"); + }); }); }); diff --git a/src/routes/admin.tsx b/src/routes/admin.tsx index 3c5dd5b..983b6ec 100644 --- a/src/routes/admin.tsx +++ b/src/routes/admin.tsx @@ -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 = () => ( - - - - -); - -const CheckIcon = () => ( - - - -); - type CopyFieldInlineProps = { value: string; emailAddress?: string; @@ -256,151 +223,6 @@ const CopyFieldInline = ({ value }: CopyFieldInlineProps) => ( ); -const OpenIcon = () => ( - -); - -const ValidateIcon = () => ( - -); - -const FORMAT_LABELS: Record = { - 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 ( -
- {label} - - - - - - - - - - - - - - - - - -
- ); -}; - -const FeedFormats = ({ - feedId, - env, - compact, -}: { - feedId: string; - env: Env; - compact?: boolean; -}) => ( -
- {!compact && Subscribe} -
- - - -
-
-); - -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 ( - - {label} - - ); -}; - const ConfirmationPill = ({ feedId }: { feedId: string }) => ( Confirmation pending diff --git a/src/routes/admin/emails.tsx b/src/routes/admin/emails.tsx index 748c5f0..613cc3a 100644 --- a/src/routes/admin/emails.tsx +++ b/src/routes/admin/emails.tsx @@ -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(); -// ── Shared SVG icons ────────────────────────────────────────────────────────── - -const CopyIcon = () => ( - - - - -); - -const CheckIcon = () => ( - - - -); - 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( @@ -189,36 +157,13 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
-

Feed Details

-
- - - -
-
+ {feedConfig.expires_at && ( +
+ +
+ )} + +
{feedMetadata.pendingConfirmation && ( diff --git a/src/routes/admin/ui.tsx b/src/routes/admin/ui.tsx index 742cd0e..0d59aad 100644 --- a/src/routes/admin/ui.tsx +++ b/src/routes/admin/ui.tsx @@ -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 = () => ( + + + + +); + +export const CheckIcon = () => ( + + + +); + +const OpenIcon = () => ( + +); + +const ValidateIcon = () => ( + +); + +// ── Feed format chips ("Subscribe" block) ───────────────────────────────────── + +const FORMAT_LABELS: Record = { + 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 ( +
+ {label} + + + + + + + + + + + + + + + + + +
+ ); +}; + +export const FeedFormats = ({ + feedId, + env, + compact, +}: { + feedId: string; + env: Env; + compact?: boolean; +}) => ( +
+ {!compact && Subscribe} +
+ + + +
+
+); + +// ── 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 ( + + {label} + + ); +}; diff --git a/src/styles/components.css b/src/styles/components.css index 0c2ea5f..c172f3c 100644 --- a/src/styles/components.css +++ b/src/styles/components.css @@ -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 {