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)
|
- Bulk feed/email deletion from the admin dashboard (safe checkbox-based flow)
|
||||||
- Inline double-confirm delete interactions with toast feedback in the admin dashboard
|
- Inline double-confirm delete interactions with toast feedback in the admin dashboard
|
||||||
- Resizable + sortable table columns in the admin dashboard (Table view)
|
- 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`)
|
- 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)
|
- **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)
|
- 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}`;
|
return `${baseUrl(env)}/atom/${feedId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function feedJsonUrl(feedId: string, env: Env): string {
|
||||||
|
return `${baseUrl(env)}/json/${feedId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function feedUrl(
|
export function feedUrl(
|
||||||
format: "rss" | "atom",
|
format: "rss" | "atom",
|
||||||
feedId: string,
|
feedId: string,
|
||||||
@@ -21,6 +25,34 @@ export function feedUrl(
|
|||||||
return format === "rss" ? feedRssUrl(feedId, env) : feedAtomUrl(feedId, env);
|
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 {
|
export function feedEmailAddress(mailboxId: string, env: Env): string {
|
||||||
// The mailbox→address shape lives on the VO; this edge only resolves the domain.
|
// The mailbox→address shape lives on the VO; this edge only resolves the domain.
|
||||||
return MailboxId.unchecked(mailboxId).emailAddress(
|
return MailboxId.unchecked(mailboxId).emailAddress(
|
||||||
|
|||||||
@@ -189,6 +189,13 @@ describe("Admin Routes", () => {
|
|||||||
expect(html).toContain(`${mailboxId}@test.getmynews.app`);
|
expect(html).toContain(`${mailboxId}@test.getmynews.app`);
|
||||||
expect(html).toContain(`/rss/${feedId}`);
|
expect(html).toContain(`/rss/${feedId}`);
|
||||||
expect(html).not.toContain(`/rss/${mailboxId}`);
|
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 () => {
|
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 { FeedId } from "../domain/value-objects/feed-id";
|
||||||
import { editFeedDetails } from "../application/feed-service";
|
import { editFeedDetails } from "../application/feed-service";
|
||||||
import {
|
import {
|
||||||
feedRssUrl,
|
|
||||||
feedAtomUrl,
|
|
||||||
feedEmailAddress,
|
feedEmailAddress,
|
||||||
|
feedFormatUrl,
|
||||||
|
feedValidatorUrl,
|
||||||
|
type FeedFormat,
|
||||||
} from "../infrastructure/urls";
|
} from "../infrastructure/urls";
|
||||||
import { feedsRouter } from "./admin/feeds";
|
import { feedsRouter } from "./admin/feeds";
|
||||||
import { emailsRouter } from "./admin/emails";
|
import { emailsRouter } from "./admin/emails";
|
||||||
@@ -255,6 +256,122 @@ 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 } {
|
function formatExpiry(expiresAt: number): { label: string; expired: boolean } {
|
||||||
const remaining = expiresAt - Date.now();
|
const remaining = expiresAt - Date.now();
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
@@ -522,8 +639,7 @@ app.get("/", async (c) => {
|
|||||||
<col data-col="title" style="width: 280px;" />
|
<col data-col="title" style="width: 280px;" />
|
||||||
<col data-col="feedId" style="width: 150px;" />
|
<col data-col="feedId" style="width: 150px;" />
|
||||||
<col data-col="email" style="width: 200px;" />
|
<col data-col="email" style="width: 200px;" />
|
||||||
<col data-col="rss" style="width: 190px;" />
|
<col data-col="formats" style="width: 230px;" />
|
||||||
<col data-col="atom" style="width: 190px;" />
|
|
||||||
<col data-col="expires" style="width: 130px;" />
|
<col data-col="expires" style="width: 130px;" />
|
||||||
<col data-col="actions" style="width: 170px;" />
|
<col data-col="actions" style="width: 170px;" />
|
||||||
</colgroup>
|
</colgroup>
|
||||||
@@ -602,47 +718,11 @@ app.get("/", async (c) => {
|
|||||||
title="Resize"
|
title="Resize"
|
||||||
></div>
|
></div>
|
||||||
</th>
|
</th>
|
||||||
<th
|
<th class="th-resizable">
|
||||||
class="th-resizable"
|
<span>Formats</span>
|
||||||
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>
|
|
||||||
<div
|
<div
|
||||||
class="col-resizer"
|
class="col-resizer"
|
||||||
data-col="rss"
|
data-col="formats"
|
||||||
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"
|
|
||||||
title="Resize"
|
title="Resize"
|
||||||
></div>
|
></div>
|
||||||
</th>
|
</th>
|
||||||
@@ -670,15 +750,11 @@ app.get("/", async (c) => {
|
|||||||
feed.mailbox_id,
|
feed.mailbox_id,
|
||||||
env,
|
env,
|
||||||
);
|
);
|
||||||
const rssUrl = feedRssUrl(feed.id, env);
|
|
||||||
const atomUrl = feedAtomUrl(feed.id, env);
|
|
||||||
const titleDisplay = clampText(feed.title, 160);
|
const titleDisplay = clampText(feed.title, 160);
|
||||||
const titleHover = clampText(feed.title, 1000);
|
const titleHover = clampText(feed.title, 1000);
|
||||||
const sortTitle = titleHover.toLowerCase();
|
const sortTitle = titleHover.toLowerCase();
|
||||||
const sortFeedId = feed.id.toLowerCase();
|
const sortFeedId = feed.id.toLowerCase();
|
||||||
const sortEmail = emailAddress.toLowerCase();
|
const sortEmail = emailAddress.toLowerCase();
|
||||||
const sortRss = rssUrl.toLowerCase();
|
|
||||||
const sortAtom = atomUrl.toLowerCase();
|
|
||||||
const descDisplay = clampText(
|
const descDisplay = clampText(
|
||||||
feed.description || "",
|
feed.description || "",
|
||||||
220,
|
220,
|
||||||
@@ -698,8 +774,6 @@ app.get("/", async (c) => {
|
|||||||
data-sort-title={sortTitle}
|
data-sort-title={sortTitle}
|
||||||
data-sort-feed-id={sortFeedId}
|
data-sort-feed-id={sortFeedId}
|
||||||
data-sort-email={sortEmail}
|
data-sort-email={sortEmail}
|
||||||
data-sort-rss={sortRss}
|
|
||||||
data-sort-atom={sortAtom}
|
|
||||||
>
|
>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
@@ -743,10 +817,7 @@ app.get("/", async (c) => {
|
|||||||
<CopyFieldInline value={emailAddress} />
|
<CopyFieldInline value={emailAddress} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<CopyFieldInline value={rssUrl} />
|
<FeedFormats feedId={feed.id} env={env} compact />
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<CopyFieldInline value={atomUrl} />
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{feed.expires_at ? (
|
{feed.expires_at ? (
|
||||||
@@ -827,8 +898,6 @@ app.get("/", async (c) => {
|
|||||||
<ul class="feed-list">
|
<ul class="feed-list">
|
||||||
{feedsWithConfig.map((feed) => {
|
{feedsWithConfig.map((feed) => {
|
||||||
const emailAddress = feedEmailAddress(feed.mailbox_id, env);
|
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 titleDisplay = clampText(feed.title, 140);
|
||||||
const titleHover = clampText(feed.title, 1000);
|
const titleHover = clampText(feed.title, 1000);
|
||||||
const descDisplay = clampText(feed.description || "", 240);
|
const descDisplay = clampText(feed.description || "", 240);
|
||||||
@@ -884,38 +953,7 @@ app.get("/", async (c) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="copyable">
|
<FeedFormats feedId={feed.id} env={env} />
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="feed-buttons">
|
<div class="feed-buttons">
|
||||||
|
|||||||
@@ -139,14 +139,14 @@ function setupFeedTableResizing(): void {
|
|||||||
title: 220,
|
title: 220,
|
||||||
feedId: 120,
|
feedId: 120,
|
||||||
email: 160,
|
email: 160,
|
||||||
rss: 160,
|
formats: 200,
|
||||||
actions: 160,
|
actions: 160,
|
||||||
};
|
};
|
||||||
const defaultWidths: Record<string, number> = {
|
const defaultWidths: Record<string, number> = {
|
||||||
title: 340,
|
title: 340,
|
||||||
feedId: 160,
|
feedId: 160,
|
||||||
email: 220,
|
email: 220,
|
||||||
rss: 220,
|
formats: 230,
|
||||||
actions: 200,
|
actions: 200,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -887,6 +887,103 @@ table.table code {
|
|||||||
flex-wrap: wrap;
|
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 {
|
.button-delete {
|
||||||
transition:
|
transition:
|
||||||
background-color 180ms ease,
|
background-color 180ms ease,
|
||||||
|
|||||||
Reference in New Issue
Block a user