mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(admin): paperclip indicator for emails with attachments
Show an inline paperclip icon before the subject in the admin email list when an email has attachments, with the count in a tooltip. Uses the attachmentIds already stored in metadata, so no extra fetch. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -676,6 +676,58 @@ describe("Admin Routes", () => {
|
||||
} | null;
|
||||
expect(metadataAfter?.emails.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should show a paperclip indicator only for emails with attachments", async () => {
|
||||
const authCookie = await loginAndGetCookie();
|
||||
const formData = new FormData();
|
||||
formData.append("title", "Email Feed");
|
||||
|
||||
const createRes = await request("/admin/feeds/create", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: authCookie,
|
||||
Origin: "https://test.getmynews.app",
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
expect(createRes.status).toBe(302);
|
||||
|
||||
const feedList = (await mockEnv.EMAIL_STORAGE.get(
|
||||
"feeds:list",
|
||||
"json",
|
||||
)) as { feeds: Array<{ id: string; title: string }> } | null;
|
||||
const feedId = feedList?.feeds[0].id as string;
|
||||
|
||||
await mockEnv.EMAIL_STORAGE.put(
|
||||
`feed:${feedId}:metadata`,
|
||||
JSON.stringify({
|
||||
emails: [
|
||||
{
|
||||
key: `feed:${feedId}:1`,
|
||||
subject: "With attachments",
|
||||
receivedAt: 2,
|
||||
attachmentIds: ["att-1", "att-2"],
|
||||
},
|
||||
{
|
||||
key: `feed:${feedId}:2`,
|
||||
subject: "No attachments",
|
||||
receivedAt: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await request(`/admin/feeds/${feedId}/emails`, {
|
||||
headers: { Cookie: authCookie },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.text();
|
||||
|
||||
expect(body).toContain("2 attachments");
|
||||
const indicatorCount = (body.match(/attachment-indicator/g) || [])
|
||||
.length;
|
||||
expect(indicatorCount).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -349,6 +349,10 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
||||
{feedMetadata.emails.map((email: EmailMetadata) => {
|
||||
const subjectDisplay = clampText(email.subject, 180);
|
||||
const subjectHover = clampText(email.subject, 1000);
|
||||
const attachmentCount = email.attachmentIds?.length ?? 0;
|
||||
const attachmentLabel = `${attachmentCount} attachment${
|
||||
attachmentCount > 1 ? "s" : ""
|
||||
}`;
|
||||
const sortSubject = subjectHover.toLowerCase();
|
||||
const sortReceivedAt = String(email.receivedAt);
|
||||
const searchHaystack = clampText(
|
||||
@@ -373,9 +377,32 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="subject-cell">
|
||||
{attachmentCount > 0 ? (
|
||||
<span
|
||||
class="attachment-indicator"
|
||||
title={attachmentLabel}
|
||||
aria-label={attachmentLabel}
|
||||
>
|
||||
<svg
|
||||
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="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
<span class="truncate" title={subjectHover}>
|
||||
{subjectDisplay}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>{new Date(email.receivedAt).toLocaleString()}</td>
|
||||
<td>
|
||||
@@ -415,7 +442,11 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
||||
</div>
|
||||
|
||||
{/* Config bootstrap — injects dynamic server-side data before the static compiled script */}
|
||||
<script dangerouslySetInnerHTML={{ __html: `window.__APP_CONFIG__=${JSON.stringify({ feedId })}` }} />
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `window.__APP_CONFIG__=${JSON.stringify({ feedId })}`,
|
||||
}}
|
||||
/>
|
||||
{/* Emails page logic compiled from src/scripts/client/emails-page.ts */}
|
||||
<script dangerouslySetInnerHTML={{ __html: emailsPageScript }} />
|
||||
</Layout>,
|
||||
@@ -445,9 +476,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
||||
})();
|
||||
|
||||
const rawHtml = emailData.content
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const rawHtml = emailData.content.replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
const viewScript = `
|
||||
function showRendered() {
|
||||
@@ -546,10 +575,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
|
||||
value={new Date(emailData.receivedAt).toLocaleString()}
|
||||
/>
|
||||
<SenderField from={emailData.from} feedId={feedId} />
|
||||
<CopyField
|
||||
label="To:"
|
||||
value={feedEmailAddress(feedId, env)}
|
||||
/>
|
||||
<CopyField label="To:" value={feedEmailAddress(feedId, env)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -769,6 +769,24 @@ table.table code {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.subject-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.subject-cell .truncate {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.attachment-indicator {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* Compact copy-to-clipboard for table cells */
|
||||
.copyable.copyable-inline {
|
||||
margin-bottom: 0;
|
||||
|
||||
Reference in New Issue
Block a user