chore: init project

This commit is contained in:
cesnimda
2026-06-30 15:53:32 +02:00
commit f43ef5f945
94 changed files with 4405 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
import axios from 'axios';
// Cookie-based auth (Google OAuth2 session), so send credentials with each call.
const api = axios.create({ baseURL: '/api/v1', withCredentials: true });
api.interceptors.response.use(
(r) => r,
(err) => {
if (err.response?.status === 401) {
// Not signed in — kick off the Google login flow.
window.location.href = '/api/v1/auth/login?returnUrl=/';
}
return Promise.reject(err);
}
);
export const AuthApi = {
me: () => api.get('/auth/me').then((r) => r.data),
logout: () => api.post('/auth/logout')
};
export const SyncApi = {
status: () => api.get('/sync/status').then((r) => r.data),
full: () => api.post('/sync/full'),
incremental: () => api.post('/sync/incremental')
};
export const AnalyticsApi = {
dashboard: () => api.get('/analytics/dashboard').then((r) => r.data),
health: () => api.get('/analytics/health').then((r) => r.data),
topSenders: (take = 20) => api.get(`/analytics/top-senders?take=${take}`).then((r) => r.data),
volume: (days = 90) => api.get(`/analytics/volume?days=${days}`).then((r) => r.data),
heatmap: () => api.get('/analytics/heatmap').then((r) => r.data),
attachments: () => api.get('/analytics/attachments').then((r) => r.data)
};
export const CleanupApi = {
preview: (req) => api.post('/cleanup/preview', req).then((r) => r.data),
execute: (req) => api.post('/cleanup/execute', req).then((r) => r.data)
};
export const UnsubscribeApi = {
detect: () => api.post('/unsubscribe/detect'),
safeList: () => api.get('/unsubscribe/safe-list').then((r) => r.data),
process: (req) => api.post('/unsubscribe/process', req).then((r) => r.data)
};
export const LayoutApi = {
get: () => api.get('/widgetlayout').then((r) => r.data),
save: (layout) => api.put('/widgetlayout', layout)
};
export const ExportApi = {
reportUrl: (format) => `/api/v1/export/report?format=${format}`
};
export default api;
+39
View File
@@ -0,0 +1,39 @@
import { Link, Outlet, useLocation } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { AuthApi, SyncApi } from '../api/client.js';
export default function Layout() {
const [user, setUser] = useState(null);
const loc = useLocation();
useEffect(() => {
AuthApi.me().then(setUser).catch(() => {});
}, []);
const nav = [
{ to: '/', label: 'Dashboard' },
{ to: '/cleanup', label: 'Cleanup' },
{ to: '/unsubscribe', label: 'Unsubscribe' }
];
return (
<div className="app">
<header className="topbar">
<div className="brand">📥 InboxIntel</div>
<nav>
{nav.map((n) => (
<Link key={n.to} to={n.to} className={loc.pathname === n.to ? 'active' : ''}>
{n.label}
</Link>
))}
</nav>
<div className="spacer" />
<button onClick={() => SyncApi.incremental()}>Sync now</button>
<span className="user">{user?.email}</span>
</header>
<main>
<Outlet />
</main>
</div>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { Bar, Line, Doughnut } from 'react-chartjs-2';
import {
Chart as ChartJS, CategoryScale, LinearScale, BarElement, PointElement,
LineElement, ArcElement, Tooltip, Legend
} from 'chart.js';
ChartJS.register(CategoryScale, LinearScale, BarElement, PointElement, LineElement, ArcElement, Tooltip, Legend);
const fmtBytes = (b) => {
if (!b) return '0 B';
const u = ['B', 'KB', 'MB', 'GB']; let i = 0; let n = b;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${n.toFixed(1)} ${u[i]}`;
};
export function StatCard({ label, value }) {
return (
<div className="widget stat">
<div className="stat-value">{value}</div>
<div className="stat-label">{label}</div>
</div>
);
}
export function HealthWidget({ health }) {
if (!health) return <Empty />;
return (
<div className="widget">
<h3>Inbox Health</h3>
<div className={`health-score grade-${health.grade}`}>{health.score}<span>/100</span></div>
<div className="grade">Grade {health.grade}</div>
<ul className="recs">{health.recommendations.map((r, i) => <li key={i}>{r}</li>)}</ul>
</div>
);
}
export function TopSendersWidget({ senders }) {
if (!senders) return <Empty />;
return (
<div className="widget">
<h3>Top Senders</h3>
<table className="mini">
<tbody>
{senders.map((s) => (
<tr key={s.senderId}>
<td title={s.address}>{s.displayName || s.address}</td>
<td className="num">{s.emailCount}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function VolumeWidget({ volume }) {
if (!volume) return <Empty />;
const data = {
labels: volume.map((p) => p.day),
datasets: [{ label: 'Emails', data: volume.map((p) => p.count), borderColor: '#4f8cff', tension: 0.3 }]
};
return (
<div className="widget">
<h3>Email Volume</h3>
<Line data={data} options={{ plugins: { legend: { display: false } }, maintainAspectRatio: false }} />
</div>
);
}
export function HeatmapWidget({ heatmap }) {
if (!heatmap) return <Empty />;
const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const max = Math.max(1, ...heatmap.map((c) => c.count));
const grid = {};
heatmap.forEach((c) => { grid[`${c.dayOfWeek}-${c.hour}`] = c.count; });
return (
<div className="widget">
<h3>Activity Heatmap</h3>
<div className="heatmap">
{days.map((d, dow) => (
<div className="hm-row" key={dow}>
<span className="hm-day">{d}</span>
{Array.from({ length: 24 }, (_, h) => {
const v = grid[`${dow}-${h}`] || 0;
return <span key={h} className="hm-cell" style={{ opacity: 0.1 + 0.9 * (v / max) }} title={`${d} ${h}:00 — ${v}`} />;
})}
</div>
))}
</div>
</div>
);
}
export function AttachmentsWidget({ attachments }) {
if (!attachments) return <Empty />;
const data = {
labels: attachments.map((a) => a.mimeBucket),
datasets: [{ data: attachments.map((a) => a.totalBytes), backgroundColor: ['#4f8cff', '#6fcf97', '#f2c94c', '#eb5757', '#bb6bd9', '#56ccf2', '#a0a0a0'] }]
};
return (
<div className="widget">
<h3>Attachments by Type</h3>
<Doughnut data={data} options={{ plugins: { legend: { position: 'right' } }, maintainAspectRatio: false }} />
<div className="muted">Total: {fmtBytes(attachments.reduce((s, a) => s + a.totalBytes, 0))}</div>
</div>
);
}
export function StorageWidget({ bytes }) {
return <StatCard label="Estimated Storage" value={fmtBytes(bytes)} />;
}
function Empty() { return <div className="widget"><div className="muted">No data yet run a sync.</div></div>; }
+22
View File
@@ -0,0 +1,22 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import Dashboard from './pages/Dashboard.jsx';
import Cleanup from './pages/Cleanup.jsx';
import Unsubscribe from './pages/Unsubscribe.jsx';
import Layout from './components/Layout.jsx';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/cleanup" element={<Cleanup />} />
<Route path="/unsubscribe" element={<Unsubscribe />} />
</Route>
</Routes>
</BrowserRouter>
</React.StrictMode>
);
+63
View File
@@ -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 &amp; {ACTIONS.find((a) => a.v === action)?.label}
</button>
</div>
)}
{result && <div className="card success">Done: {result.succeededCount} succeeded, {result.failedCount} failed.</div>}
</div>
);
}
+101
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+63
View File
@@ -0,0 +1,63 @@
:root {
--bg: #0f1420;
--panel: #1a2030;
--panel-2: #222a3d;
--text: #e6e9f0;
--muted: #8b93a7;
--accent: #4f8cff;
--danger: #eb5757;
--ok: #6fcf97;
}
* { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: var(--bg); color: var(--text); }
.topbar { display: flex; align-items: center; gap: 18px; padding: 12px 20px; background: var(--panel); border-bottom: 1px solid #2c3550; }
.brand { font-weight: 700; font-size: 18px; }
.topbar nav a { color: var(--muted); text-decoration: none; margin-right: 14px; }
.topbar nav a.active, .topbar nav a:hover { color: var(--text); }
.spacer { flex: 1; }
.user { color: var(--muted); font-size: 13px; }
main { padding: 20px; }
button, .btn { background: var(--accent); color: #fff; border: none; border-radius: 6px; padding: 8px 14px; cursor: pointer; text-decoration: none; font-size: 14px; }
button.danger, .btn.danger { background: var(--danger); }
button:disabled { opacity: 0.5; cursor: default; }
.toolbar { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.widget-toggles { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--muted); }
.grid-item { background: var(--panel); border: 1px solid #2c3550; border-radius: 10px; overflow: hidden; }
.widget { padding: 14px; height: 100%; display: flex; flex-direction: column; }
.widget h3 { margin: 0 0 10px; font-size: 14px; cursor: move; }
.widget canvas { flex: 1; min-height: 0; }
.stat { align-items: flex-start; justify-content: center; }
.stat-value { font-size: 34px; font-weight: 700; }
.stat-label { color: var(--muted); font-size: 13px; cursor: move; }
.health-score { font-size: 46px; font-weight: 800; }
.health-score span { font-size: 18px; color: var(--muted); }
.grade-A { color: var(--ok); } .grade-B { color: #9bdf6f; } .grade-C { color: #f2c94c; }
.grade-D { color: #f2994a; } .grade-F { color: var(--danger); }
.recs { margin: 8px 0 0; padding-left: 16px; font-size: 12px; color: var(--muted); }
table.mini, table.grid { width: 100%; border-collapse: collapse; font-size: 13px; }
table.mini td { padding: 3px 0; }
table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; text-align: left; }
.num { text-align: right; }
.muted { color: var(--muted); font-size: 12px; }
.heatmap { display: flex; flex-direction: column; gap: 2px; }
.hm-row { display: flex; align-items: center; gap: 2px; }
.hm-day { width: 30px; font-size: 10px; color: var(--muted); }
.hm-cell { width: 10px; height: 10px; background: var(--accent); border-radius: 2px; }
.page { max-width: 900px; }
.form-row { display: flex; gap: 10px; margin: 14px 0; }
.form-row input { flex: 1; }
input, select { background: var(--panel-2); border: 1px solid #2c3550; color: var(--text); border-radius: 6px; padding: 8px; }
.card { background: var(--panel); border: 1px solid #2c3550; border-radius: 10px; padding: 14px; margin-top: 12px; }
.card.success { border-color: var(--ok); }
.card ul { font-size: 13px; color: var(--muted); }