feat: quick actions on email rows (read/unread, star, trash)

- EmailRow shared component with hover action buttons: mark read/unread, star/unstar, trash
- Optimistic UI — local state patches immediately on click; row fades and becomes non-interactive during the API call
- Trashed rows disappear from the current folder view via onRemove callback
- Backend: EmailController single-email endpoints (POST /email/{id}/read|unread|star|unstar|trash|untrash)
- Star/Unstar added to CleanupActionType enum and CleanupService (maps to STARRED Gmail label via BatchModifyAsync)
- UserId scope enforced in ResolveTargetsAsync — a user can only act on their own emails
- action buttons use stopPropagation so clicking them does not open Gmail

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 20:18:44 +02:00
parent 498536451e
commit 2f6d6abdd3
8 changed files with 181 additions and 75 deletions
+5 -37
View File
@@ -1,6 +1,7 @@
import { useEffect, useState, useCallback, useRef } from 'react';
import { useParams } from 'react-router-dom';
import { SearchApi } from '../api/client.js';
import EmailRow from '../components/EmailRow.jsx';
const FOLDER_META = {
inbox: { icon: '📥', label: 'Inbox' },
@@ -31,20 +32,6 @@ const FOLDER_META = {
const PAGE_SIZE = 50;
const fmtDate = (iso) => {
const d = new Date(iso);
const now = new Date();
const diffDays = (now - d) / 86400000;
if (diffDays < 1) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
if (diffDays < 7) return d.toLocaleDateString([], { weekday: 'short' });
return d.toLocaleDateString([], { month: 'short', day: 'numeric' });
};
const fmtSize = (b) => {
if (b < 1024) return `${b} B`;
if (b < 1048576) return `${(b / 1024).toFixed(0)} KB`;
return `${(b / 1048576).toFixed(1)} MB`;
};
export default function FolderView() {
const { slug } = useParams();
@@ -119,30 +106,11 @@ export default function FolderView() {
<table className="email-list">
<tbody>
{emails.map((e) => (
<tr
<EmailRow
key={e.id}
className={`email-row${e.isUnread ? ' email-row--unread' : ''}`}
onClick={() => window.open(
`https://mail.google.com/mail/u/0/#all/${e.gmailMessageId}`,
'_blank',
'noopener,noreferrer'
)}
title="Open in Gmail"
>
<td className="el-unread">{e.isUnread && <span className="unread-dot" />}</td>
<td className="el-sender" title={e.senderAddress}>
{e.senderDisplayName || e.senderAddress}
</td>
<td className="el-subject">
<span className="el-subj-text">{e.subject || '(no subject)'}</span>
{e.snippet && <span className="el-snippet"> {e.snippet}</span>}
</td>
<td className="el-meta">
{e.hasAttachments && <span className="el-attach" title="Has attachment">📎</span>}
{e.sizeEstimateBytes > 1048576 && <span className="el-size">{fmtSize(e.sizeEstimateBytes)}</span>}
</td>
<td className="el-date">{fmtDate(e.sentAtUtc)}</td>
</tr>
email={e}
onRemove={(id) => setEmails((prev) => prev.filter((x) => x.id !== id))}
/>
))}
</tbody>
</table>