chore: init project
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
import { useState } from 'react';
|
||||
import { CleanupApi } from '../api/client.js';
|
||||
|
||||
const ACTIONS = [
|
||||
{ v: 0, label: 'Archive' },
|
||||
{ v: 1, label: 'Trash' },
|
||||
{ v: 5, label: 'Mark read' },
|
||||
{ v: 6, label: 'Mark unread' }
|
||||
];
|
||||
|
||||
// Mirrors CleanupActionType: 1 = Trash, 2 = HardDelete are destructive.
|
||||
const isDestructive = (a) => a === 1 || a === 2;
|
||||
|
||||
export default function Cleanup() {
|
||||
const [query, setQuery] = useState('from:newsletter is:read');
|
||||
const [action, setAction] = useState(0);
|
||||
const [preview, setPreview] = useState(null);
|
||||
const [result, setResult] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const doPreview = async () => {
|
||||
setResult(null); setBusy(true);
|
||||
try { setPreview(await CleanupApi.preview({ action, query, emailIds: null, labelId: null, confirmed: false })); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
const doExecute = async () => {
|
||||
if (isDestructive(action) && !window.confirm(`This will ${ACTIONS.find((a) => a.v === action)?.label} ${preview?.affectedCount} emails. Continue?`)) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const res = await CleanupApi.execute({ action, query, emailIds: null, labelId: null, confirmed: true });
|
||||
setResult(res); setPreview(null);
|
||||
} finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>Bulk Cleanup</h2>
|
||||
<p className="muted">Preview is required before any action runs. Destructive actions ask for confirmation.</p>
|
||||
|
||||
<div className="form-row">
|
||||
<input value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Gmail-like query, e.g. from:github.com is:read" />
|
||||
<select value={action} onChange={(e) => setAction(Number(e.target.value))}>
|
||||
{ACTIONS.map((a) => <option key={a.v} value={a.v}>{a.label}</option>)}
|
||||
</select>
|
||||
<button onClick={doPreview} disabled={busy}>Preview</button>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div className="preview card">
|
||||
<strong>{preview.affectedCount}</strong> emails match
|
||||
({(preview.affectedSizeBytes / 1048576).toFixed(1)} MB).
|
||||
<ul>{preview.sample.map((e) => <li key={e.id}>{e.subject} — <span className="muted">{e.senderAddress}</span></li>)}</ul>
|
||||
<button className={isDestructive(action) ? 'danger' : ''} onClick={doExecute} disabled={busy}>
|
||||
Confirm & {ACTIONS.find((a) => a.v === action)?.label}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && <div className="card success">Done: {result.succeededCount} succeeded, {result.failedCount} failed.</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import GridLayout from 'react-grid-layout';
|
||||
import 'react-grid-layout/css/styles.css';
|
||||
import 'react-resizable/css/styles.css';
|
||||
import { AnalyticsApi, LayoutApi, ExportApi } from '../api/client.js';
|
||||
import {
|
||||
HealthWidget, StatCard, TopSendersWidget, VolumeWidget,
|
||||
HeatmapWidget, AttachmentsWidget, StorageWidget
|
||||
} from '../components/widgets.jsx';
|
||||
|
||||
// Default grid geometry; overridden by the user's saved layout.
|
||||
const DEFAULT_LAYOUT = [
|
||||
{ i: 'inbox-health', x: 0, y: 0, w: 3, h: 5 },
|
||||
{ i: 'total-emails', x: 3, y: 0, w: 2, h: 2 },
|
||||
{ i: 'unread-emails', x: 5, y: 0, w: 2, h: 2 },
|
||||
{ i: 'storage', x: 7, y: 0, w: 2, h: 2 },
|
||||
{ i: 'top-senders', x: 3, y: 2, w: 3, h: 5 },
|
||||
{ i: 'volume', x: 6, y: 2, w: 6, h: 4 },
|
||||
{ i: 'heatmap', x: 0, y: 5, w: 6, h: 4 },
|
||||
{ i: 'attachments', x: 6, y: 6, w: 4, h: 4 }
|
||||
];
|
||||
|
||||
const ALL_WIDGETS = DEFAULT_LAYOUT.map((l) => l.i);
|
||||
|
||||
export default function Dashboard() {
|
||||
const [data, setData] = useState(null);
|
||||
const [layout, setLayout] = useState(DEFAULT_LAYOUT);
|
||||
const [hidden, setHidden] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
AnalyticsApi.dashboard().then(setData).catch(() => {});
|
||||
LayoutApi.get().then((saved) => {
|
||||
if (saved?.length) {
|
||||
setLayout(saved.map((w) => ({ i: w.widgetKey, x: w.x, y: w.y, w: w.w, h: w.h })));
|
||||
setHidden(saved.filter((w) => !w.visible).map((w) => w.widgetKey));
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const persist = (nextLayout, nextHidden) => {
|
||||
const dto = nextLayout.map((l, idx) => ({
|
||||
widgetKey: l.i, x: l.x, y: l.y, w: l.w, h: l.h,
|
||||
visible: !nextHidden.includes(l.i), sortOrder: idx, settingsJson: null
|
||||
}));
|
||||
LayoutApi.save(dto).catch(() => {});
|
||||
};
|
||||
|
||||
const onLayoutChange = (l) => { setLayout(l); persist(l, hidden); };
|
||||
const toggle = (key) => {
|
||||
const next = hidden.includes(key) ? hidden.filter((h) => h !== key) : [...hidden, key];
|
||||
setHidden(next); persist(layout, next);
|
||||
};
|
||||
|
||||
const visibleLayout = useMemo(() => layout.filter((l) => !hidden.includes(l.i)), [layout, hidden]);
|
||||
|
||||
const render = (key) => {
|
||||
switch (key) {
|
||||
case 'inbox-health': return <HealthWidget health={data?.health} />;
|
||||
case 'total-emails': return <StatCard label="Total Emails" value={(data?.totalEmails ?? 0).toLocaleString()} />;
|
||||
case 'unread-emails': return <StatCard label="Unread" value={(data?.unreadEmails ?? 0).toLocaleString()} />;
|
||||
case 'storage': return <StorageWidget bytes={data?.storageEstimateBytes} />;
|
||||
case 'top-senders': return <TopSendersWidget senders={data?.topSenders} />;
|
||||
case 'volume': return <VolumeWidget volume={data?.volumeOverTime} />;
|
||||
case 'heatmap': return <HeatmapWidget heatmap={data?.heatmap} />;
|
||||
case 'attachments': return <AttachmentsWidget attachments={data?.attachmentBreakdown} />;
|
||||
default: return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="toolbar">
|
||||
<div className="widget-toggles">
|
||||
{ALL_WIDGETS.map((k) => (
|
||||
<label key={k}>
|
||||
<input type="checkbox" checked={!hidden.includes(k)} onChange={() => toggle(k)} /> {k}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="spacer" />
|
||||
<a className="btn" href={ExportApi.reportUrl('pdf')}>Export PDF</a>
|
||||
<a className="btn" href={ExportApi.reportUrl('csv')}>CSV</a>
|
||||
<a className="btn" href={ExportApi.reportUrl('json')}>JSON</a>
|
||||
</div>
|
||||
|
||||
<GridLayout
|
||||
className="layout"
|
||||
layout={visibleLayout}
|
||||
cols={12}
|
||||
rowHeight={60}
|
||||
width={1200}
|
||||
onLayoutChange={onLayoutChange}
|
||||
draggableHandle=".widget h3, .widget .stat-label"
|
||||
>
|
||||
{visibleLayout.map((l) => (
|
||||
<div key={l.i} className="grid-item">{render(l.i)}</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { UnsubscribeApi } from '../api/client.js';
|
||||
|
||||
const METHOD = ['None', 'HTTP link', 'mailto', 'One-click'];
|
||||
const STATUS = ['Detected', 'Queued', 'In progress', 'Succeeded', 'Failed', 'Skipped'];
|
||||
|
||||
export default function Unsubscribe() {
|
||||
const [items, setItems] = useState([]);
|
||||
const [selected, setSelected] = useState({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const load = async () => setItems(await UnsubscribeApi.safeList());
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const detect = async () => { setBusy(true); try { await UnsubscribeApi.detect(); await load(); } finally { setBusy(false); } };
|
||||
|
||||
const process = async () => {
|
||||
const ids = Object.keys(selected).filter((k) => selected[k]);
|
||||
if (!ids.length) return;
|
||||
if (!window.confirm(`Unsubscribe from ${ids.length} sender(s)?`)) return;
|
||||
setBusy(true);
|
||||
try { await UnsubscribeApi.process({ itemIds: ids, confirmed: true }); await load(); setSelected({}); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<h2>Unsubscribe Manager</h2>
|
||||
<div className="form-row">
|
||||
<button onClick={detect} disabled={busy}>Re-scan for subscriptions</button>
|
||||
<button onClick={process} disabled={busy} className="danger">Unsubscribe selected</button>
|
||||
</div>
|
||||
<table className="grid">
|
||||
<thead><tr><th></th><th>Sender</th><th>Domain</th><th>Method</th><th>Emails</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{items.map((it) => (
|
||||
<tr key={it.id}>
|
||||
<td><input type="checkbox" checked={!!selected[it.id]} onChange={(e) => setSelected({ ...selected, [it.id]: e.target.checked })} /></td>
|
||||
<td>{it.senderAddress}</td>
|
||||
<td>{it.domain}</td>
|
||||
<td>{METHOD[it.method]}</td>
|
||||
<td className="num">{it.emailCount}</td>
|
||||
<td>{STATUS[it.status]}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!items.length && <tr><td colSpan="6" className="muted">Nothing detected yet. Run a scan.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user