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-banner");
|
||||||
expect(body).toContain("confirmation-dismiss");
|
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 { ADMIN_COOKIE_MAX_AGE } from "../config/constants";
|
||||||
import { logger } from "../infrastructure/logger";
|
import { logger } from "../infrastructure/logger";
|
||||||
import { timingSafeEqual, checkProxyAuth } from "../infrastructure/auth";
|
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 { FeedRepository } from "../infrastructure/feed-repository";
|
||||||
import { FeedId } from "../domain/value-objects/feed-id";
|
import { FeedId } from "../domain/value-objects/feed-id";
|
||||||
import { editFeedDetails } from "../application/feed-service";
|
import { editFeedDetails } from "../application/feed-service";
|
||||||
import {
|
import { feedEmailAddress } from "../infrastructure/urls";
|
||||||
feedEmailAddress,
|
|
||||||
feedFormatUrl,
|
|
||||||
feedValidatorUrl,
|
|
||||||
type FeedFormat,
|
|
||||||
} from "../infrastructure/urls";
|
|
||||||
import { feedsRouter } from "./admin/feeds";
|
import { feedsRouter } from "./admin/feeds";
|
||||||
import { emailsRouter } from "./admin/emails";
|
import { emailsRouter } from "./admin/emails";
|
||||||
import { handleOpml } from "./opml";
|
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`.
|
// dashboardScript is compiled from src/scripts/client/dashboard.ts via `npm run build:client`.
|
||||||
// It is imported from src/scripts/generated/dashboard.ts above.
|
// 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 = {
|
type CopyFieldInlineProps = {
|
||||||
value: string;
|
value: string;
|
||||||
emailAddress?: string;
|
emailAddress?: string;
|
||||||
@@ -256,151 +223,6 @@ const CopyFieldInline = ({ value }: CopyFieldInlineProps) => (
|
|||||||
</div>
|
</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 }) => (
|
const ConfirmationPill = ({ feedId }: { feedId: string }) => (
|
||||||
<a class="pill pill-confirmation" href={`/admin/feeds/${feedId}/emails`}>
|
<a class="pill pill-confirmation" href={`/admin/feeds/${feedId}/emails`}>
|
||||||
Confirmation pending
|
Confirmation pending
|
||||||
|
|||||||
+15
-70
@@ -1,7 +1,14 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { Env, EmailMetadata } from "../../types";
|
import { Env, EmailMetadata } from "../../types";
|
||||||
import { logger } from "../../infrastructure/logger";
|
import { logger } from "../../infrastructure/logger";
|
||||||
import { Layout, clampText } from "./ui";
|
import {
|
||||||
|
Layout,
|
||||||
|
clampText,
|
||||||
|
CopyIcon,
|
||||||
|
CheckIcon,
|
||||||
|
FeedFormats,
|
||||||
|
ExpiryBadge,
|
||||||
|
} from "./ui";
|
||||||
import {
|
import {
|
||||||
deleteAttachmentsForEmails,
|
deleteAttachmentsForEmails,
|
||||||
deleteKeysWithConcurrency,
|
deleteKeysWithConcurrency,
|
||||||
@@ -9,8 +16,6 @@ import {
|
|||||||
import { FeedRepository } from "../../infrastructure/feed-repository";
|
import { FeedRepository } from "../../infrastructure/feed-repository";
|
||||||
import { FeedId } from "../../domain/value-objects/feed-id";
|
import { FeedId } from "../../domain/value-objects/feed-id";
|
||||||
import {
|
import {
|
||||||
feedRssUrl,
|
|
||||||
feedAtomUrl,
|
|
||||||
feedEmailAddress,
|
feedEmailAddress,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
entryPath,
|
entryPath,
|
||||||
@@ -25,41 +30,6 @@ type AppEnv = { Bindings: Env };
|
|||||||
|
|
||||||
export const emailsRouter = new Hono<AppEnv>();
|
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 = {
|
type CopyFieldProps = {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -171,8 +141,6 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const emailAddress = feedEmailAddress(feedConfig.mailbox_id, env);
|
const emailAddress = feedEmailAddress(feedConfig.mailbox_id, env);
|
||||||
const rssUrl = feedRssUrl(feedId, env);
|
|
||||||
const atomUrl = feedAtomUrl(feedId, env);
|
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title={`${feedConfig.title} - Emails`}>
|
<Layout title={`${feedConfig.title} - Emails`}>
|
||||||
@@ -189,36 +157,13 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Feed Details</h2>
|
{feedConfig.expires_at && (
|
||||||
<div>
|
<div class="feed-header">
|
||||||
<CopyField label="Email Address:" value={emailAddress} />
|
<ExpiryBadge expiresAt={feedConfig.expires_at} />
|
||||||
<CopyField label="RSS Feed:" value={rssUrl} />
|
</div>
|
||||||
<CopyField label="Atom Feed:" value={atomUrl} />
|
)}
|
||||||
</div>
|
<CopyField label="Email:" value={emailAddress} />
|
||||||
<div class="feed-validate">
|
<FeedFormats feedId={feedId} env={env} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{feedMetadata.pendingConfirmation && (
|
{feedMetadata.pendingConfirmation && (
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import componentsCss from "../../styles/components.css";
|
|||||||
import utilitiesCss from "../../styles/utilities.css";
|
import utilitiesCss from "../../styles/utilities.css";
|
||||||
import { interactiveScripts } from "../../scripts/index";
|
import { interactiveScripts } from "../../scripts/index";
|
||||||
import { FAVICON_PATH } from "../favicon";
|
import { FAVICON_PATH } from "../favicon";
|
||||||
|
import { Env } from "../../types";
|
||||||
|
import {
|
||||||
|
feedFormatUrl,
|
||||||
|
feedValidatorUrl,
|
||||||
|
type FeedFormat,
|
||||||
|
} from "../../infrastructure/urls";
|
||||||
|
|
||||||
const designSystem = [
|
const designSystem = [
|
||||||
variablesCss,
|
variablesCss,
|
||||||
@@ -89,3 +95,187 @@ export function clampText(value: string, maxLen: number): string {
|
|||||||
}
|
}
|
||||||
return `${raw.slice(0, maxLen - 3).trimEnd()}...`;
|
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);
|
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 and Email Lists */
|
||||||
.feed-list,
|
.feed-list,
|
||||||
.email-list {
|
.email-list {
|
||||||
|
|||||||
Reference in New Issue
Block a user