mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
@@ -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
@@ -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
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user