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;
|
} | null;
|
||||||
expect(metadataAfter?.emails.length).toBe(0);
|
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) => {
|
{feedMetadata.emails.map((email: EmailMetadata) => {
|
||||||
const subjectDisplay = clampText(email.subject, 180);
|
const subjectDisplay = clampText(email.subject, 180);
|
||||||
const subjectHover = clampText(email.subject, 1000);
|
const subjectHover = clampText(email.subject, 1000);
|
||||||
|
const attachmentCount = email.attachmentIds?.length ?? 0;
|
||||||
|
const attachmentLabel = `${attachmentCount} attachment${
|
||||||
|
attachmentCount > 1 ? "s" : ""
|
||||||
|
}`;
|
||||||
const sortSubject = subjectHover.toLowerCase();
|
const sortSubject = subjectHover.toLowerCase();
|
||||||
const sortReceivedAt = String(email.receivedAt);
|
const sortReceivedAt = String(email.receivedAt);
|
||||||
const searchHaystack = clampText(
|
const searchHaystack = clampText(
|
||||||
@@ -373,9 +377,32 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<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}>
|
<span class="truncate" title={subjectHover}>
|
||||||
{subjectDisplay}
|
{subjectDisplay}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>{new Date(email.receivedAt).toLocaleString()}</td>
|
<td>{new Date(email.receivedAt).toLocaleString()}</td>
|
||||||
<td>
|
<td>
|
||||||
@@ -415,7 +442,11 @@ emailsRouter.get("/feeds/:feedId/emails", async (c) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Config bootstrap — injects dynamic server-side data before the static compiled script */}
|
{/* 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 */}
|
{/* Emails page logic compiled from src/scripts/client/emails-page.ts */}
|
||||||
<script dangerouslySetInnerHTML={{ __html: emailsPageScript }} />
|
<script dangerouslySetInnerHTML={{ __html: emailsPageScript }} />
|
||||||
</Layout>,
|
</Layout>,
|
||||||
@@ -445,9 +476,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
|
|||||||
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
return btoa(String.fromCharCode(...new Uint8Array(bytes)));
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const rawHtml = emailData.content
|
const rawHtml = emailData.content.replace(/</g, "<").replace(/>/g, ">");
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">");
|
|
||||||
|
|
||||||
const viewScript = `
|
const viewScript = `
|
||||||
function showRendered() {
|
function showRendered() {
|
||||||
@@ -546,10 +575,7 @@ emailsRouter.get("/emails/:emailKey", async (c) => {
|
|||||||
value={new Date(emailData.receivedAt).toLocaleString()}
|
value={new Date(emailData.receivedAt).toLocaleString()}
|
||||||
/>
|
/>
|
||||||
<SenderField from={emailData.from} feedId={feedId} />
|
<SenderField from={emailData.from} feedId={feedId} />
|
||||||
<CopyField
|
<CopyField label="To:" value={feedEmailAddress(feedId, env)} />
|
||||||
label="To:"
|
|
||||||
value={feedEmailAddress(feedId, env)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -769,6 +769,24 @@ table.table code {
|
|||||||
max-width: 100%;
|
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 */
|
/* Compact copy-to-clipboard for table cells */
|
||||||
.copyable.copyable-inline {
|
.copyable.copyable-inline {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user