mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(status): redesign status page with hero, themed sections, responsive grid
Rework the public / status page from a flat uniform grid into a hero featured metric plus four themed sections (Feeds, Emails, Distribution, Instance). Add semantic colors (green success, red rejects/deletes), relative timestamps with UTC tooltips, and derived metrics (net feeds, acceptance rate, avg emails/feed, humanized uptime). Grid is fluid above 640px (auto-fit) and locks to two columns on mobile. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+149
-22
@@ -7,24 +7,81 @@ function formatDateTime(iso?: string): string {
|
|||||||
if (!iso) return "Never";
|
if (!iso) return "Never";
|
||||||
const date = new Date(iso);
|
const date = new Date(iso);
|
||||||
if (Number.isNaN(date.getTime())) return "Never";
|
if (Number.isNaN(date.getTime())) return "Never";
|
||||||
return date.toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC");
|
return date
|
||||||
|
.toISOString()
|
||||||
|
.replace("T", " ")
|
||||||
|
.replace(/\.\d+Z$/, " UTC");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRelative(iso?: string): string {
|
||||||
|
if (!iso) return "Never";
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return "Never";
|
||||||
|
const diffMs = Date.now() - date.getTime();
|
||||||
|
if (diffMs < 0) return "just now";
|
||||||
|
const seconds = Math.floor(diffMs / 1000);
|
||||||
|
if (seconds < 60) return "just now";
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime(iso?: string): string {
|
||||||
|
if (!iso) return "—";
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (Number.isNaN(date.getTime())) return "—";
|
||||||
|
const diffMs = Date.now() - date.getTime();
|
||||||
|
if (diffMs < 0) return "—";
|
||||||
|
const minutes = Math.floor(diffMs / 60000);
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days} ${days === 1 ? "day" : "days"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tone = "success" | "danger";
|
||||||
|
|
||||||
|
type StatProps = {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
tone?: Tone;
|
||||||
|
title?: string;
|
||||||
|
time?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Stat = ({ label, value, tone, title, time }: StatProps) => {
|
||||||
|
const valueClass = [
|
||||||
|
"stat-value",
|
||||||
|
time ? "stat-value-time" : "",
|
||||||
|
tone ? `stat-value-${tone}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
return (
|
||||||
|
<div class="card stat-card" title={title}>
|
||||||
|
<span class={valueClass}>{value}</span>
|
||||||
|
<span class="stat-label">{label}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||||
const stats = await getStats(c.env);
|
const stats = await getStats(c.env);
|
||||||
|
|
||||||
const cards: Array<{ label: string; value: string | number; time?: boolean }> =
|
const netFeeds = stats.feeds_created - stats.feeds_deleted;
|
||||||
[
|
const totalEmails = stats.emails_received + stats.emails_rejected;
|
||||||
{ label: "Active feeds", value: stats.active_feeds },
|
const acceptanceRate =
|
||||||
{ label: "Feeds created", value: stats.feeds_created },
|
totalEmails > 0
|
||||||
{ label: "Feeds deleted", value: stats.feeds_deleted },
|
? `${Math.round((stats.emails_received / totalEmails) * 100)}%`
|
||||||
{ label: "Emails received", value: stats.emails_received },
|
: "—";
|
||||||
{ label: "Emails rejected", value: stats.emails_rejected },
|
const avgEmailsPerFeed =
|
||||||
{ label: "WebSub subscribers", value: stats.websub_subscriptions_active },
|
stats.feeds_created > 0
|
||||||
{ label: "Last email", value: formatDateTime(stats.last_email_at), time: true },
|
? (stats.emails_received / stats.feeds_created).toFixed(1)
|
||||||
{ label: "Last feed created", value: formatDateTime(stats.last_feed_created_at), time: true },
|
: "—";
|
||||||
{ label: "Online since", value: formatDateTime(stats.first_seen), time: true },
|
|
||||||
];
|
|
||||||
|
|
||||||
return c.html(
|
return c.html(
|
||||||
<Layout title="Status" label="status">
|
<Layout title="Status" label="status">
|
||||||
@@ -41,16 +98,86 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-grid">
|
<div class="card stat-hero">
|
||||||
{cards.map((card) => (
|
<div class="stat-hero-main">
|
||||||
<div class="card stat-card">
|
<span class="stat-hero-value">{stats.active_feeds}</span>
|
||||||
<span class={`stat-value${card.time ? " stat-value-time" : ""}`}>
|
<span class="stat-hero-label">Active feeds</span>
|
||||||
{card.value}
|
</div>
|
||||||
</span>
|
<div class="stat-hero-aside">
|
||||||
<span class="stat-label">{card.label}</span>
|
<span class="stat-hero-aside-value">{stats.emails_received}</span>
|
||||||
</div>
|
<span class="stat-hero-aside-label">Emails received</span>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="stat-section">
|
||||||
|
<h2 class="stat-section-title">Feeds</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<Stat label="Feeds created" value={stats.feeds_created} />
|
||||||
|
<Stat
|
||||||
|
label="Feeds deleted"
|
||||||
|
value={stats.feeds_deleted}
|
||||||
|
tone="danger"
|
||||||
|
/>
|
||||||
|
<Stat label="Net feeds" value={netFeeds} />
|
||||||
|
<Stat
|
||||||
|
label="Last feed created"
|
||||||
|
value={formatRelative(stats.last_feed_created_at)}
|
||||||
|
title={formatDateTime(stats.last_feed_created_at)}
|
||||||
|
time
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-section">
|
||||||
|
<h2 class="stat-section-title">Emails</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<Stat
|
||||||
|
label="Emails received"
|
||||||
|
value={stats.emails_received}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Emails rejected"
|
||||||
|
value={stats.emails_rejected}
|
||||||
|
tone="danger"
|
||||||
|
/>
|
||||||
|
<Stat
|
||||||
|
label="Acceptance rate"
|
||||||
|
value={acceptanceRate}
|
||||||
|
tone="success"
|
||||||
|
/>
|
||||||
|
<Stat label="Avg emails / feed" value={avgEmailsPerFeed} />
|
||||||
|
<Stat
|
||||||
|
label="Last email"
|
||||||
|
value={formatRelative(stats.last_email_at)}
|
||||||
|
title={formatDateTime(stats.last_email_at)}
|
||||||
|
time
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-section">
|
||||||
|
<h2 class="stat-section-title">Distribution</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<Stat
|
||||||
|
label="WebSub subscribers"
|
||||||
|
value={stats.websub_subscriptions_active}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-section">
|
||||||
|
<h2 class="stat-section-title">Instance</h2>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<Stat label="Uptime" value={formatUptime(stats.first_seen)} time />
|
||||||
|
<Stat
|
||||||
|
label="Online since"
|
||||||
|
value={formatRelative(stats.first_seen)}
|
||||||
|
title={formatDateTime(stats.first_seen)}
|
||||||
|
time
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</Layout>,
|
</Layout>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1016,7 +1016,7 @@ table.table code {
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
gap: var(--spacing-md);
|
gap: var(--spacing-md);
|
||||||
margin-bottom: var(--spacing-lg);
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.stat-card {
|
.stat-card {
|
||||||
@@ -1039,7 +1039,100 @@ table.table code {
|
|||||||
color: var(--color-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stat-value-success {
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value-danger {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.stat-label {
|
.stat-label {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status page — hero featured metric */
|
||||||
|
.stat-hero {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-lg);
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-hero-main {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-hero-value {
|
||||||
|
font-size: 3.25rem;
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-hero-label {
|
||||||
|
font-size: var(--font-size-md);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-hero-aside {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-hero-aside-value {
|
||||||
|
font-size: var(--font-size-xxl);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-hero-aside-label {
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status page — thematic sections */
|
||||||
|
.stat-section {
|
||||||
|
margin-bottom: var(--spacing-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-section-title {
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin: 0 0 var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.stat-hero-aside {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
gap: var(--spacing-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
padding: var(--spacing-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value-time {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user